diff --git a/scripts/compare-benchmarks.ts b/scripts/compare-benchmarks.ts index 757a2a3..530a84b 100644 --- a/scripts/compare-benchmarks.ts +++ b/scripts/compare-benchmarks.ts @@ -1,5 +1,7 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; + +// ─── Types ──────────────────────────────────────────────────────────────────── interface BenchmarkResult { query: string; @@ -19,200 +21,358 @@ interface BenchmarkFile { }; } -/** - * Compare two benchmark results to show performance improvements - */ -class BenchmarkComparator { - compareFiles(beforeFile: string, afterFile: string): void { - const before = this.loadBenchmark(beforeFile); - const after = this.loadBenchmark(afterFile); +interface QueryDiff { + description: string; + before: number; + after: number; + diff: number; + percent: number; + rowsBefore: number; + rowsAfter: number; +} - if (!before || !after) { - console.error('❌ Could not load benchmark files'); - return; - } +interface ComparisonReport { + before: BenchmarkFile; + after: BenchmarkFile; + diffs: QueryDiff[]; + totalTimeDiff: number; + totalTimePercent: number; + avgTimeDiff: number; + avgTimePercent: number; + improvements: QueryDiff[]; + regressions: QueryDiff[]; +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +const COL = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +}; + +function c(color: keyof typeof COL, text: string): string { + return `${COL[color]}${text}${COL.reset}`; +} + +function bold(text: string): string { + return `${COL.bold}${text}${COL.reset}`; +} + +function formatMs(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms.toFixed(2)}ms`; +} + +function formatChange(diff: number, percent: number): string { + const sign = diff > 0 ? "+" : ""; + const pStr = `${sign}${percent.toFixed(1)}%`; + const mStr = `${sign}${formatMs(diff)}`; + + if (diff < 0) return c("green", `▼ ${mStr} (${pStr})`); + if (diff > 0) return c("red", `▲ ${mStr} (${pStr})`); + return c("gray", `─ no change`); +} + +function bar(percent: number, width = 20): string { + const improvement = Math.min(Math.abs(percent), 100) / 100; + const filled = Math.round(improvement * width); + const empty = width - filled; + const color: keyof typeof COL = percent < 0 ? "green" : "red"; + return c(color, "█".repeat(filled)) + c("gray", "░".repeat(empty)); +} + +function hr(char = "─", width = 100): string { + return c("gray", char.repeat(width)); +} - this.printComparison(before, after); +// ─── Core logic ─────────────────────────────────────────────────────────────── + +function buildReport(before: BenchmarkFile, after: BenchmarkFile): ComparisonReport { + const diffs: QueryDiff[] = []; + + for (const b of before.results) { + if (b.executionTime < 0) continue; + const a = after.results.find((r) => r.description === b.description); + if (!a || a.executionTime < 0) continue; + + const diff = a.executionTime - b.executionTime; + const percent = (diff / b.executionTime) * 100; + diffs.push({ + description: b.description, + before: b.executionTime, + after: a.executionTime, + diff, + percent, + rowsBefore: b.rowsReturned, + rowsAfter: a.rowsReturned, + }); } - private loadBenchmark(filename: string): BenchmarkFile | null { - try { - const filePath = path.join(process.cwd(), 'benchmark-results', filename); - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - console.error(`Error loading ${filename}:`, error.message); - return null; + const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; + const totalTimePercent = (totalTimeDiff / before.summary.totalTime) * 100; + const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; + const avgTimePercent = (avgTimeDiff / before.summary.avgTime) * 100; + + const improvements = [...diffs].filter((d) => d.percent < 0).sort((a, b) => a.percent - b.percent); + const regressions = [...diffs].filter((d) => d.percent > 0).sort((a, b) => b.percent - a.percent); + + return { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions }; +} + +// ─── Printer ───────────────────────────────────────────────────────────────── + +function printReport(report: ComparisonReport): void { + const { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions } = report; + + // Header + console.log("\n" + hr("═")); + console.log(bold(" 📊 BENCHMARK COMPARISON REPORT")); + console.log(hr("═")); + console.log(` ${c("gray", "Before:")} ${before.timestamp} ${c("gray", "After:")} ${after.timestamp}`); + + // Summary cards + console.log("\n" + hr()); + console.log(bold(" Overall Summary")); + console.log(hr()); + + const summaryRows: [string, string, string, string][] = [ + ["Metric", "Before", "After", "Change"], + ["Total time", formatMs(before.summary.totalTime), formatMs(after.summary.totalTime), formatChange(totalTimeDiff, totalTimePercent)], + ["Avg time", formatMs(before.summary.avgTime), formatMs(after.summary.avgTime), formatChange(avgTimeDiff, avgTimePercent)], + ["Queries run", String(before.summary.totalQueries), String(after.summary.totalQueries), "─"], + ["Successful", String(before.summary.successful), String(after.summary.successful), "─"], + ]; + + const colW = [22, 12, 12, 30]; + for (const [i, row] of summaryRows.entries()) { + const line = row.map((cell, ci) => (ci === 3 ? cell : cell.padEnd(colW[ci]))).join(" "); + console.log(" " + (i === 0 ? bold(c("cyan", line)) : line)); + } + + // Per-query table + console.log("\n" + hr()); + console.log(bold(" Per-Query Breakdown")); + console.log(hr()); + + const header = [ + "Query".padEnd(46), + "Before".padStart(10), + "After".padStart(10), + "Rows".padStart(7), + " Change", + ].join(" "); + console.log(" " + bold(c("cyan", header))); + console.log(hr()); + + for (const d of diffs) { + const rowChange = + d.rowsAfter !== d.rowsBefore + ? c("yellow", ` (rows: ${d.rowsBefore}→${d.rowsAfter})`) + : c("gray", ` (${d.rowsAfter})`); + + const line = [ + d.description.substring(0, 44).padEnd(46), + formatMs(d.before).padStart(10), + formatMs(d.after).padStart(10), + rowChange.padStart(7), + " " + formatChange(d.diff, d.percent), + ].join(" "); + + console.log(" " + line); + } + + // Improvements + if (improvements.length > 0) { + console.log("\n" + hr()); + console.log(bold(` 🚀 Top Improvements (${improvements.length} queries faster)`)); + console.log(hr()); + + for (const [i, item] of improvements.slice(0, 5).entries()) { + const pct = Math.abs(item.percent); + console.log(` ${c("green", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("green", `${pct.toFixed(1)}% faster`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); } } - private printComparison(before: BenchmarkFile, after: BenchmarkFile): void { - console.log('\n' + '='.repeat(100)); - console.log('📊 BENCHMARK COMPARISON REPORT'); - console.log('='.repeat(100) + '\n'); - - console.log(`Before: ${before.timestamp}`); - console.log(`After: ${after.timestamp}\n`); - - // Overall summary - console.log('Overall Performance:'); - console.log('-'.repeat(100)); - - const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; - const totalTimePercent = ((totalTimeDiff / before.summary.totalTime) * 100); - const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; - const avgTimePercent = ((avgTimeDiff / before.summary.avgTime) * 100); - - console.log(`Total Execution Time: ${before.summary.totalTime.toFixed(2)}ms → ${after.summary.totalTime.toFixed(2)}ms`); - console.log(` ${this.formatChange(totalTimeDiff, totalTimePercent)}`); - - console.log(`\nAverage Execution Time: ${before.summary.avgTime.toFixed(2)}ms → ${after.summary.avgTime.toFixed(2)}ms`); - console.log(` ${this.formatChange(avgTimeDiff, avgTimePercent)}`); - - // Individual query comparison - console.log('\n\nIndividual Query Performance:'); - console.log('-'.repeat(100)); - console.log( - `${'Query'.padEnd(50)} | ${'Before'.padStart(10)} | ${'After'.padStart(10)} | ${'Change'.padStart(15)}` - ); - console.log('-'.repeat(100)); - - const improvements: Array<{ description: string; improvement: number }> = []; - - before.results.forEach((beforeResult) => { - const afterResult = after.results.find( - (r) => r.description === beforeResult.description - ); - - if (!afterResult || beforeResult.executionTime < 0 || afterResult.executionTime < 0) { - return; - } - - const diff = afterResult.executionTime - beforeResult.executionTime; - const percent = ((diff / beforeResult.executionTime) * 100); - - const description = beforeResult.description.substring(0, 48); - const beforeTime = `${beforeResult.executionTime.toFixed(2)}ms`; - const afterTime = `${afterResult.executionTime.toFixed(2)}ms`; - const change = this.formatChange(diff, percent, false); - - console.log( - `${description.padEnd(50)} | ${beforeTime.padStart(10)} | ${afterTime.padStart(10)} | ${change.padStart(15)}` - ); - - if (percent < 0) { - improvements.push({ - description: beforeResult.description, - improvement: Math.abs(percent), - }); - } - }); + // Regressions + if (regressions.length > 0) { + console.log("\n" + hr()); + console.log(bold(` ⚠️ Regressions (${regressions.length} queries slower)`)); + console.log(hr()); - // Top improvements - if (improvements.length > 0) { - console.log('\n\nTop Performance Improvements:'); - console.log('-'.repeat(100)); - - improvements - .sort((a, b) => b.improvement - a.improvement) - .slice(0, 5) - .forEach((item, index) => { - console.log(`${index + 1}. ${item.description}`); - console.log(` 🚀 ${item.improvement.toFixed(1)}% faster\n`); - }); + for (const [i, item] of regressions.slice(0, 5).entries()) { + console.log(` ${c("red", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("red", `${item.percent.toFixed(1)}% slower`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); } + } + + // Rating + console.log("\n" + hr("═")); + printRating(avgTimePercent, improvements.length, regressions.length, diffs.length); + console.log(hr("═") + "\n"); +} + +function printRating(avgPct: number, improved: number, regressed: number, total: number): void { + let rating: string; + if (avgPct < -50) rating = c("green", "🌟🌟🌟 EXCELLENT — Queries are dramatically faster"); + else if (avgPct < -25) rating = c("green", "🌟🌟 GREAT — Substantial performance improvement"); + else if (avgPct < -10) rating = c("green", "🌟 GOOD — Noticeable performance improvement"); + else if (avgPct < 0) rating = c("green", "✅ IMPROVED — Slight performance improvement"); + else if (avgPct < 10) rating = c("yellow", "⚠️ NEUTRAL — Minimal performance change"); + else rating = c("red", "❌ DEGRADED — Performance has decreased"); + + const improvedPct = total > 0 ? ((improved / total) * 100).toFixed(0) : "0"; + const regressedPct = total > 0 ? ((regressed / total) * 100).toFixed(0) : "0"; + + console.log(` ${bold("Rating:")} ${rating}`); + console.log( + ` ${c("gray", `${improved}/${total} queries improved (${improvedPct}%)`)}` + + (regressed > 0 ? ` ${c("gray", `· ${regressed} regressed (${regressedPct}%)`)}` : "") + ); +} + +// ─── JSON export ───────────────────────────────────────────────────────────── + +function exportReport(report: ComparisonReport, outputPath: string): void { + const json = { + generatedAt: new Date().toISOString(), + before: report.before.timestamp, + after: report.after.timestamp, + summary: { + totalTimeChange: { ms: report.totalTimeDiff, percent: report.totalTimePercent }, + avgTimeChange: { ms: report.avgTimeDiff, percent: report.avgTimePercent }, + queriesImproved: report.improvements.length, + queriesRegressed: report.regressions.length, + queriesUnchanged: report.diffs.length - report.improvements.length - report.regressions.length, + }, + topImprovements: report.improvements.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + improvementPercent: Math.abs(d.percent), + })), + topRegressions: report.regressions.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + regressionPercent: d.percent, + })), + allDiffs: report.diffs, + }; - // Performance rating - console.log('\n' + '='.repeat(100)); - this.printPerformanceRating(avgTimePercent); - console.log('='.repeat(100) + '\n'); + fs.writeFileSync(outputPath, JSON.stringify(json, null, 2)); + console.log(`\n ${c("cyan", "📄")} Report exported to ${c("cyan", outputPath)}\n`); +} + +// ─── BenchmarkComparator class ──────────────────────────────────────────────── + +export class BenchmarkComparator { + private readonly resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? path.join(process.cwd(), "benchmark-results"); } - private formatChange(diff: number, percent: number, includeEmoji: boolean = true): string { - const emoji = includeEmoji - ? diff < 0 - ? '🟢' - : diff > 0 - ? '🔴' - : '⚪' - : ''; - - const sign = diff > 0 ? '+' : ''; - const percentStr = `${sign}${percent.toFixed(1)}%`; - const diffStr = `${sign}${diff.toFixed(2)}ms`; - - return `${emoji} ${diffStr} (${percentStr})`; + compareFiles(beforeFile: string, afterFile: string, opts: { export?: string } = {}): void { + const before = this.loadBenchmark(beforeFile); + const after = this.loadBenchmark(afterFile); + + if (!before || !after) { + console.error(c("red", "❌ Could not load one or both benchmark files.")); + process.exit(1); + } + + const report = buildReport(before, after); + printReport(report); + + if (opts.export) { + exportReport(report, opts.export); + } } - private printPerformanceRating(avgTimePercent: number): void { - console.log('Performance Rating:'); - - if (avgTimePercent < -50) { - console.log('🌟🌟🌟 EXCELLENT - Queries are significantly faster!'); - } else if (avgTimePercent < -25) { - console.log('🌟🌟 GREAT - Substantial performance improvement!'); - } else if (avgTimePercent < -10) { - console.log('🌟 GOOD - Noticeable performance improvement'); - } else if (avgTimePercent < 0) { - console.log('✅ IMPROVED - Slight performance improvement'); - } else if (avgTimePercent < 10) { - console.log('⚠️ NEUTRAL - Minimal performance change'); - } else { - console.log('❌ DEGRADED - Performance has decreased'); + private loadBenchmark(filename: string): BenchmarkFile | null { + const filePath = path.isAbsolute(filename) ? filename : path.join(this.resultsDir, filename); + try { + const content = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(content) as BenchmarkFile; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(c("red", ` Error loading "${filename}": ${msg}`)); + return null; } } listAvailableBenchmarks(): string[] { - const resultsDir = path.join(process.cwd(), 'benchmark-results'); - - if (!fs.existsSync(resultsDir)) { - console.log('No benchmark results directory found'); + if (!fs.existsSync(this.resultsDir)) { + console.log(c("yellow", " No benchmark-results directory found.")); return []; } - const files = fs.readdirSync(resultsDir) - .filter(f => f.startsWith('benchmark-') && f.endsWith('.json')) + return fs + .readdirSync(this.resultsDir) + .filter((f) => f.startsWith("benchmark-") && f.endsWith(".json")) .sort(); - - return files; } } -// CLI execution -async function main() { +// ─── CLI ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { const comparator = new BenchmarkComparator(); const args = process.argv.slice(2); - if (args.length === 0) { - console.log('📁 Available benchmark files:\n'); + // Parse flags + const exportFlag = args.indexOf("--export"); + let exportPath: string | undefined; + let positional = args; + + if (exportFlag !== -1) { + exportPath = args[exportFlag + 1]; + if (!exportPath) { + console.error(c("red", "❌ --export requires a file path argument")); + process.exit(1); + } + positional = args.filter((_, i) => i !== exportFlag && i !== exportFlag + 1); + } + + if (positional.length === 0) { + console.log(bold("\n 📁 Available benchmark files:\n")); const files = comparator.listAvailableBenchmarks(); - + if (files.length === 0) { - console.log('No benchmark files found. Run "npm run benchmark:indexes" first.'); + console.log(c("yellow", ' No benchmark files found. Run "npm run benchmark:indexes" first.\n')); return; } - files.forEach((file, index) => { - console.log(`${index + 1}. ${file}`); + files.forEach((file, i) => { + console.log(` ${c("gray", `${i + 1}.`)} ${file}`); }); - console.log('\nUsage: npm run benchmark:compare '); - console.log('Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json'); + console.log(c("gray", "\n Usage: npm run benchmark:compare [--export out.json]")); + console.log(c("gray", " Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json\n")); return; } - if (args.length !== 2) { - console.error('❌ Please provide exactly two benchmark files to compare'); - console.log('Usage: npm run benchmark:compare '); - return; + if (positional.length !== 2) { + console.error(c("red", "❌ Please provide exactly two benchmark files to compare.")); + console.error(c("gray", " Usage: npm run benchmark:compare ")); + process.exit(1); } - const [beforeFile, afterFile] = args; - comparator.compareFiles(beforeFile, afterFile); + const [beforeFile, afterFile] = positional; + comparator.compareFiles(beforeFile, afterFile, { export: exportPath }); } if (require.main === module) { - main(); -} - -export { BenchmarkComparator }; + main().catch((err) => { + console.error(c("red", `❌ Unexpected error: ${err.message}`)); + process.exit(1); + }); +} \ No newline at end of file diff --git a/src/claims/claim-resolution.service.ts b/src/claims/claim-resolution.service.ts index a557eaa..dcf40c9 100644 --- a/src/claims/claim-resolution.service.ts +++ b/src/claims/claim-resolution.service.ts @@ -1,58 +1,192 @@ -import { Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { Repository, DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Claim } from './entities/claim.entity'; import { ClaimsCache } from '../cache/claims.cache'; -interface VoteWeightSummary { +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface VoteWeightSummary { trueWeight: number; falseWeight: number; } +export type Verdict = 'true' | 'false' | 'inconclusive'; + +export interface ConfidenceResult { + score: number; + verdict: Verdict; + margin: number; + participation: number; + totalWeight: number; +} + +export interface ResolutionResult { + claim: Claim; + confidence: ConfidenceResult | null; + resolvedAt: Date; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class ClaimResolutionService { + private readonly logger = new Logger(ClaimResolutionService.name); + + /** + * Minimum combined vote weight required before a claim can be resolved. + * Below this threshold the result is considered statistically unreliable. + */ private readonly MIN_REQUIRED_WEIGHT = 100; + /** + * Confidence score at or above which a non-tied result is considered + * a strong consensus. Used for logging / downstream consumers. + */ + private readonly STRONG_CONSENSUS_THRESHOLD = 0.75; + constructor( @InjectRepository(Claim) private readonly claimRepo: Repository, private readonly claimsCache: ClaimsCache, - ) { } + private readonly dataSource: DataSource, + ) {} + + // ─── Pure computation ─────────────────────────────────────────────────── + + /** + * Compute a confidence score and derived verdict from a vote weight summary. + * + * Returns `null` when the total weight falls below the minimum threshold, + * indicating insufficient participation to produce a reliable result. + * + * Score formula: + * margin = |trueWeight - falseWeight| / total (0–1) + * participation = min(total / MIN_REQUIRED_WEIGHT, 1) (0–1, capped) + * score = margin × participation (0–1) + * + * A higher participation factor rewards decisions backed by a larger + * electorate; a claim passing the minimum by a large margin is more + * trustworthy than one that barely scraped over it. + */ + computeConfidenceScore(votes: VoteWeightSummary): ConfidenceResult | null { + this.validateVotes(votes); - computeConfidenceScore(votes: VoteWeightSummary): number | null { const { trueWeight, falseWeight } = votes; const total = trueWeight + falseWeight; - // Safety rules if (total < this.MIN_REQUIRED_WEIGHT) return null; - if (trueWeight === falseWeight) return 0; const margin = Math.abs(trueWeight - falseWeight) / total; - const participation = Math.min( - total / this.MIN_REQUIRED_WEIGHT, - 1, - ); + const participation = Math.min(total / this.MIN_REQUIRED_WEIGHT, 1); + const score = Number((margin * participation).toFixed(4)); - return Number((margin * participation).toFixed(4)); + const verdict: Verdict = + trueWeight === falseWeight + ? 'inconclusive' + : trueWeight > falseWeight + ? 'true' + : 'false'; + + return { score, verdict, margin, participation, totalWeight: total }; } + // ─── Resolution ───────────────────────────────────────────────────────── + + /** + * Resolve a claim by persisting the verdict and confidence score. + * + * - Throws `NotFoundException` if the claim does not exist. + * - Throws `ConflictException` if the claim is already finalized. + * - Throws `BadRequestException` if votes fail basic validation. + * + * The DB save and cache invalidation run inside a transaction so a failed + * cache call cannot leave the claim in an inconsistent state. + */ async resolveClaim( claimId: string, votes: VoteWeightSummary, - ) { + ): Promise { + this.validateVotes(votes); + const claim = await this.claimRepo.findOneBy({ id: claimId }); - if (!claim) throw new Error('Claim not found'); + if (!claim) { + throw new NotFoundException(`Claim with ID ${claimId} not found`); + } + + if (claim.finalized) { + throw new ConflictException( + `Claim ${claimId} is already finalized and cannot be re-resolved`, + ); + } - const verdict = votes.trueWeight > votes.falseWeight; const confidence = this.computeConfidenceScore(votes); + const resolvedAt = new Date(); + + const savedClaim = await this.dataSource.transaction(async (manager) => { + if (confidence) { + claim.resolvedVerdict = confidence.verdict === 'true'; + claim.confidenceScore = confidence.score; + } else { + // Insufficient participation — record as inconclusive + claim.resolvedVerdict = null; + claim.confidenceScore = null; + } - claim.resolvedVerdict = verdict; - claim.confidenceScore = confidence; - claim.finalized = true; + claim.finalized = true; + claim.resolvedAt = resolvedAt; + + return manager.save(claim); + }); - const savedClaim = await this.claimRepo.save(claim); - // Invalidate both the claim-specific cache and the latest claims list cache await this.claimsCache.invalidateClaim(claimId); - return savedClaim; + + this.logger.log( + confidence + ? `Claim ${claimId} resolved: verdict=${confidence.verdict}, ` + + `score=${confidence.score}, margin=${confidence.margin.toFixed(3)}, ` + + `participation=${confidence.participation.toFixed(3)}` + : `Claim ${claimId} resolved as inconclusive (insufficient participation — ` + + `total weight ${votes.trueWeight + votes.falseWeight} < ${this.MIN_REQUIRED_WEIGHT})`, + ); + + if (confidence && confidence.score >= this.STRONG_CONSENSUS_THRESHOLD) { + this.logger.log(`Claim ${claimId} reached strong consensus (score ${confidence.score})`); + } + + return { claim: savedClaim, confidence, resolvedAt }; } -} + + // ─── Helpers ──────────────────────────────────────────────────────────── + + /** + * Check whether a claim has already been finalized without loading the + * full entity — useful for lightweight pre-flight checks in controllers. + */ + async isFinalized(claimId: string): Promise { + const count = await this.claimRepo.count({ + where: { id: claimId, finalized: true }, + }); + return count > 0; + } + + // ─── Private ──────────────────────────────────────────────────────────── + + private validateVotes(votes: VoteWeightSummary): void { + if (!votes) { + throw new BadRequestException('Vote weight summary is required'); + } + if (votes.trueWeight < 0 || votes.falseWeight < 0) { + throw new BadRequestException('Vote weights must be non-negative'); + } + if (!Number.isFinite(votes.trueWeight) || !Number.isFinite(votes.falseWeight)) { + throw new BadRequestException('Vote weights must be finite numbers'); + } + } +} \ No newline at end of file diff --git a/src/claims/evidence.service.ts b/src/claims/evidence.service.ts index 2a435be..cf9b20e 100644 --- a/src/claims/evidence.service.ts +++ b/src/claims/evidence.service.ts @@ -1,3 +1,117 @@ +import * as fs from "fs"; +import * as path from "path"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BenchmarkResult { + query: string; + description: string; + executionTime: number; + rowsReturned: number; +} + +interface BenchmarkFile { + timestamp: string; + results: BenchmarkResult[]; + summary: { + totalQueries: number; + successful: number; + totalTime: number; + avgTime: number; + }; +} + +interface QueryDiff { + description: string; + before: number; + after: number; + diff: number; + percent: number; + rowsBefore: number; + rowsAfter: number; +} + +interface ComparisonReport { + before: BenchmarkFile; + after: BenchmarkFile; + diffs: QueryDiff[]; + totalTimeDiff: number; + totalTimePercent: number; + avgTimeDiff: number; + avgTimePercent: number; + improvements: QueryDiff[]; + regressions: QueryDiff[]; +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +const COL = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +}; + +function c(color: keyof typeof COL, text: string): string { + return `${COL[color]}${text}${COL.reset}`; +} + +function bold(text: string): string { + return `${COL.bold}${text}${COL.reset}`; +} + +function formatMs(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms.toFixed(2)}ms`; +} + +function formatChange(diff: number, percent: number): string { + const sign = diff > 0 ? "+" : ""; + const pStr = `${sign}${percent.toFixed(1)}%`; + const mStr = `${sign}${formatMs(diff)}`; + + if (diff < 0) return c("green", `▼ ${mStr} (${pStr})`); + if (diff > 0) return c("red", `▲ ${mStr} (${pStr})`); + return c("gray", `─ no change`); +} + +function bar(percent: number, width = 20): string { + const improvement = Math.min(Math.abs(percent), 100) / 100; + const filled = Math.round(improvement * width); + const empty = width - filled; + const color: keyof typeof COL = percent < 0 ? "green" : "red"; + return c(color, "█".repeat(filled)) + c("gray", "░".repeat(empty)); +} + +function hr(char = "─", width = 100): string { + return c("gray", char.repeat(width)); +} + +// ─── Core logic ─────────────────────────────────────────────────────────────── + +function buildReport(before: BenchmarkFile, after: BenchmarkFile): ComparisonReport { + const diffs: QueryDiff[] = []; + + for (const b of before.results) { + if (b.executionTime < 0) continue; + const a = after.results.find((r) => r.description === b.description); + if (!a || a.executionTime < 0) continue; + + const diff = a.executionTime - b.executionTime; + const percent = (diff / b.executionTime) * 100; + diffs.push({ + description: b.description, + before: b.executionTime, + after: a.executionTime, + diff, + percent, + rowsBefore: b.rowsReturned, + rowsAfter: a.rowsReturned, import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -42,8 +156,47 @@ export class EvidenceService { description: `Evidence submitted for claim ${claimId} with CID: ${cid}`, afterState: { id: savedEvidence.id, claimId, version: 1, cid, hash }, }); + } + + const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; + const totalTimePercent = (totalTimeDiff / before.summary.totalTime) * 100; + const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; + const avgTimePercent = (avgTimeDiff / before.summary.avgTime) * 100; + + const improvements = [...diffs].filter((d) => d.percent < 0).sort((a, b) => a.percent - b.percent); + const regressions = [...diffs].filter((d) => d.percent > 0).sort((a, b) => b.percent - a.percent); + + return { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions }; +} + +// ─── Printer ───────────────────────────────────────────────────────────────── - return savedEvidence; +function printReport(report: ComparisonReport): void { + const { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions } = report; + + // Header + console.log("\n" + hr("═")); + console.log(bold(" 📊 BENCHMARK COMPARISON REPORT")); + console.log(hr("═")); + console.log(` ${c("gray", "Before:")} ${before.timestamp} ${c("gray", "After:")} ${after.timestamp}`); + + // Summary cards + console.log("\n" + hr()); + console.log(bold(" Overall Summary")); + console.log(hr()); + + const summaryRows: [string, string, string, string][] = [ + ["Metric", "Before", "After", "Change"], + ["Total time", formatMs(before.summary.totalTime), formatMs(after.summary.totalTime), formatChange(totalTimeDiff, totalTimePercent)], + ["Avg time", formatMs(before.summary.avgTime), formatMs(after.summary.avgTime), formatChange(avgTimeDiff, avgTimePercent)], + ["Queries run", String(before.summary.totalQueries), String(after.summary.totalQueries), "─"], + ["Successful", String(before.summary.successful), String(after.summary.successful), "─"], + ]; + + const colW = [22, 12, 12, 30]; + for (const [i, row] of summaryRows.entries()) { + const line = row.map((cell, ci) => (ci === 3 ? cell : cell.padEnd(colW[ci]))).join(" "); + console.log(" " + (i === 0 ? bold(c("cyan", line)) : line)); } async addEvidenceVersion( @@ -57,11 +210,21 @@ export class EvidenceService { throw new NotFoundException(`Evidence with ID ${evidenceId} not found`); } - const beforeState = { ...evidence }; - const newVersion = evidence.latestVersion + 1; - evidence.latestVersion = newVersion; - const updatedEvidence = await this.evidenceRepository.save(evidence); + const header = [ + "Query".padEnd(46), + "Before".padStart(10), + "After".padStart(10), + "Rows".padStart(7), + " Change", + ].join(" "); + console.log(" " + bold(c("cyan", header))); + console.log(hr()); + for (const d of diffs) { + const rowChange = + d.rowsAfter !== d.rowsBefore + ? c("yellow", ` (rows: ${d.rowsBefore}→${d.rowsAfter})`) + : c("gray", ` (${d.rowsAfter})`); const version = this.evidenceVersionRepository.create({ evidenceId, version: newVersion, @@ -81,9 +244,41 @@ export class EvidenceService { afterState: updatedEvidence, }); - return savedVersion; + const line = [ + d.description.substring(0, 44).padEnd(46), + formatMs(d.before).padStart(10), + formatMs(d.after).padStart(10), + rowChange.padStart(7), + " " + formatChange(d.diff, d.percent), + ].join(" "); + + console.log(" " + line); } + // Improvements + if (improvements.length > 0) { + console.log("\n" + hr()); + console.log(bold(` 🚀 Top Improvements (${improvements.length} queries faster)`)); + console.log(hr()); + + for (const [i, item] of improvements.slice(0, 5).entries()) { + const pct = Math.abs(item.percent); + console.log(` ${c("green", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("green", `${pct.toFixed(1)}% faster`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); + } + } + + // Regressions + if (regressions.length > 0) { + console.log("\n" + hr()); + console.log(bold(` ⚠️ Regressions (${regressions.length} queries slower)`)); + console.log(hr()); + + for (const [i, item] of regressions.slice(0, 5).entries()) { + console.log(` ${c("red", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("red", `${item.percent.toFixed(1)}% slower`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); + } + } /** * Get evidence with all versions */ @@ -121,11 +316,112 @@ export class EvidenceService { const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); if (!evidence) return null; - return this.evidenceVersionRepository.findOne({ - where: { evidenceId, version: evidence.latestVersion }, - }); + // Rating + console.log("\n" + hr("═")); + printRating(avgTimePercent, improvements.length, regressions.length, diffs.length); + console.log(hr("═") + "\n"); +} + +function printRating(avgPct: number, improved: number, regressed: number, total: number): void { + let rating: string; + if (avgPct < -50) rating = c("green", "🌟🌟🌟 EXCELLENT — Queries are dramatically faster"); + else if (avgPct < -25) rating = c("green", "🌟🌟 GREAT — Substantial performance improvement"); + else if (avgPct < -10) rating = c("green", "🌟 GOOD — Noticeable performance improvement"); + else if (avgPct < 0) rating = c("green", "✅ IMPROVED — Slight performance improvement"); + else if (avgPct < 10) rating = c("yellow", "⚠️ NEUTRAL — Minimal performance change"); + else rating = c("red", "❌ DEGRADED — Performance has decreased"); + + const improvedPct = total > 0 ? ((improved / total) * 100).toFixed(0) : "0"; + const regressedPct = total > 0 ? ((regressed / total) * 100).toFixed(0) : "0"; + + console.log(` ${bold("Rating:")} ${rating}`); + console.log( + ` ${c("gray", `${improved}/${total} queries improved (${improvedPct}%)`)}` + + (regressed > 0 ? ` ${c("gray", `· ${regressed} regressed (${regressedPct}%)`)}` : "") + ); +} + +// ─── JSON export ───────────────────────────────────────────────────────────── + +function exportReport(report: ComparisonReport, outputPath: string): void { + const json = { + generatedAt: new Date().toISOString(), + before: report.before.timestamp, + after: report.after.timestamp, + summary: { + totalTimeChange: { ms: report.totalTimeDiff, percent: report.totalTimePercent }, + avgTimeChange: { ms: report.avgTimeDiff, percent: report.avgTimePercent }, + queriesImproved: report.improvements.length, + queriesRegressed: report.regressions.length, + queriesUnchanged: report.diffs.length - report.improvements.length - report.regressions.length, + }, + topImprovements: report.improvements.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + improvementPercent: Math.abs(d.percent), + })), + topRegressions: report.regressions.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + regressionPercent: d.percent, + })), + allDiffs: report.diffs, + }; + + fs.writeFileSync(outputPath, JSON.stringify(json, null, 2)); + console.log(`\n ${c("cyan", "📄")} Report exported to ${c("cyan", outputPath)}\n`); +} + +// ─── BenchmarkComparator class ──────────────────────────────────────────────── + +export class BenchmarkComparator { + private readonly resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? path.join(process.cwd(), "benchmark-results"); + } + + compareFiles(beforeFile: string, afterFile: string, opts: { export?: string } = {}): void { + const before = this.loadBenchmark(beforeFile); + const after = this.loadBenchmark(afterFile); + + if (!before || !after) { + console.error(c("red", "❌ Could not load one or both benchmark files.")); + process.exit(1); + } + + const report = buildReport(before, after); + printReport(report); + + if (opts.export) { + exportReport(report, opts.export); + } } + private loadBenchmark(filename: string): BenchmarkFile | null { + const filePath = path.isAbsolute(filename) ? filename : path.join(this.resultsDir, filename); + try { + const content = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(content) as BenchmarkFile; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(c("red", ` Error loading "${filename}": ${msg}`)); + return null; + } + } + + listAvailableBenchmarks(): string[] { + if (!fs.existsSync(this.resultsDir)) { + console.log(c("yellow", " No benchmark-results directory found.")); + return []; + } + + return fs + .readdirSync(this.resultsDir) + .filter((f) => f.startsWith("benchmark-") && f.endsWith(".json")) + .sort(); /** * Get all evidence for a claim */ @@ -163,7 +459,39 @@ export class EvidenceService { where: { evidenceId: evidence.id, version: evidence.latestVersion }, }); } +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const comparator = new BenchmarkComparator(); + const args = process.argv.slice(2); + + // Parse flags + const exportFlag = args.indexOf("--export"); + let exportPath: string | undefined; + let positional = args; + + if (exportFlag !== -1) { + exportPath = args[exportFlag + 1]; + if (!exportPath) { + console.error(c("red", "❌ --export requires a file path argument")); + process.exit(1); + } + positional = args.filter((_, i) => i !== exportFlag && i !== exportFlag + 1); + } + + if (positional.length === 0) { + console.log(bold("\n 📁 Available benchmark files:\n")); + const files = comparator.listAvailableBenchmarks(); + if (files.length === 0) { + console.log(c("yellow", ' No benchmark files found. Run "npm run benchmark:indexes" first.\n')); + return; + } + + files.forEach((file, i) => { + console.log(` ${c("gray", `${i + 1}.`)} ${file}`); async verifyEvidence(evidenceId: string, userId?: string): Promise { const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); if (!evidence) { @@ -180,6 +508,25 @@ export class EvidenceService { afterState: evidence, }); - return evidence; + console.log(c("gray", "\n Usage: npm run benchmark:compare [--export out.json]")); + console.log(c("gray", " Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json\n")); + return; } + + if (positional.length !== 2) { + console.error(c("red", "❌ Please provide exactly two benchmark files to compare.")); + console.error(c("gray", " Usage: npm run benchmark:compare ")); + process.exit(1); + } + + const [beforeFile, afterFile] = positional; + comparator.compareFiles(beforeFile, afterFile, { export: exportPath }); +} + +if (require.main === module) { + main().catch((err) => { + console.error(c("red", `❌ Unexpected error: ${err.message}`)); + process.exit(1); + }); +} } diff --git a/src/dispute/dispute.service.ts b/src/dispute/dispute.service.ts index 0ac708b..9599ef8 100644 --- a/src/dispute/dispute.service.ts +++ b/src/dispute/dispute.service.ts @@ -1,80 +1,185 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, + Logger, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Dispute, DisputeStatus, DisputeTrigger, DisputeOutcome } from './entities/dispute.entity'; - -interface DisputeConfig { - LOW_CONFIDENCE_THRESHOLD: number; // e.g., 0.6 - MINORITY_OPPOSITION_THRESHOLD: number; // e.g., 0.3 - MAX_DISPUTE_DURATION_HOURS: number; // e.g., 72 - DISPUTE_COOLDOWN_HOURS: number; // e.g., 24 +import { Repository, DataSource } from 'typeorm'; +import { + Dispute, + DisputeStatus, + DisputeTrigger, + DisputeOutcome, +} from './entities/dispute.entity'; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +export interface DisputeConfig { + /** Confidence score below which a claim auto-triggers a dispute (0–1). */ + LOW_CONFIDENCE_THRESHOLD: number; + /** Minority opposition ratio at or above which a dispute is triggered (0–1). */ + MINORITY_OPPOSITION_THRESHOLD: number; + /** Hours after creation before an open/reviewing dispute is considered expired. */ + MAX_DISPUTE_DURATION_HOURS: number; + /** Hours that must pass before a second dispute can be raised on the same claim. */ + DISPUTE_COOLDOWN_HOURS: number; } -const DEFAULT_CONFIG: DisputeConfig = { +export const DEFAULT_CONFIG: DisputeConfig = { LOW_CONFIDENCE_THRESHOLD: 0.6, MINORITY_OPPOSITION_THRESHOLD: 0.3, MAX_DISPUTE_DURATION_HOURS: 72, DISPUTE_COOLDOWN_HOURS: 24, }; +// ─── DTOs ───────────────────────────────────────────────────────────────────── + +export interface CreateDisputeDto { + claimId: string; + trigger: DisputeTrigger; + originalConfidence: number; + initiatorId?: string; + metadata?: Record; +} + +export interface ResolveDisputeDto { + disputeId: string; + outcome: DisputeOutcome; + finalConfidence: number; + metadata?: Record; +} + +export interface RejectDisputeDto { + disputeId: string; + reason: string; + rejectedBy?: string; +} + +export interface FindAllDisputesDto { + status?: DisputeStatus; + trigger?: DisputeTrigger; + claimId?: string; + limit?: number; + offset?: number; +} + +export interface TriggerCheckResult { + shouldDispute: boolean; + trigger?: DisputeTrigger; + reason?: string; +} + +export interface PaginatedDisputes { + items: Dispute[]; + total: number; + limit: number; + offset: number; +} + +// ─── Resolvable statuses ────────────────────────────────────────────────────── + +const RESOLVABLE_STATUSES: DisputeStatus[] = [ + DisputeStatus.OPEN, + DisputeStatus.REVIEWING, +]; + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class DisputeService { + private readonly logger = new Logger(DisputeService.name); + private readonly config: DisputeConfig; + constructor( @InjectRepository(Dispute) private readonly disputeRepository: Repository, - ) {} + private readonly dataSource: DataSource, + config?: Partial, + ) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + // ─── Trigger evaluation ────────────────────────────────────────────────── /** - * Check if claim should trigger dispute + * Evaluate whether a claim's current scores warrant opening a dispute. + * + * Rules are checked in priority order — the first matching rule wins. + * Returns a full explanation alongside the boolean so callers can log or + * surface the reason without re-deriving it. */ shouldTriggerDispute( confidence: number, minorityOpposition: number, - ): { shouldDispute: boolean; trigger?: DisputeTrigger } { - if (confidence < DEFAULT_CONFIG.LOW_CONFIDENCE_THRESHOLD) { - return { shouldDispute: true, trigger: DisputeTrigger.LOW_CONFIDENCE }; + ): TriggerCheckResult { + this.validateConfidence(confidence, 'confidence'); + this.validateConfidence(minorityOpposition, 'minorityOpposition'); + + if (confidence < this.config.LOW_CONFIDENCE_THRESHOLD) { + return { + shouldDispute: true, + trigger: DisputeTrigger.LOW_CONFIDENCE, + reason: `Confidence ${confidence.toFixed(4)} is below threshold ${this.config.LOW_CONFIDENCE_THRESHOLD}`, + }; } - if (minorityOpposition >= DEFAULT_CONFIG.MINORITY_OPPOSITION_THRESHOLD) { - return { shouldDispute: true, trigger: DisputeTrigger.MINORITY_OPPOSITION }; + if (minorityOpposition >= this.config.MINORITY_OPPOSITION_THRESHOLD) { + return { + shouldDispute: true, + trigger: DisputeTrigger.MINORITY_OPPOSITION, + reason: `Minority opposition ${minorityOpposition.toFixed(4)} meets threshold ${this.config.MINORITY_OPPOSITION_THRESHOLD}`, + }; } return { shouldDispute: false }; } + // ─── Create ────────────────────────────────────────────────────────────── + /** - * Create dispute for a claim + * Open a new dispute for a claim. + * + * Guards: + * - Throws `ConflictException` if an OPEN dispute already exists. + * - Throws `BadRequestException` if the cooldown window has not elapsed. + * - Throws `BadRequestException` if `originalConfidence` is outside [0, 1]. */ - async createDispute( - claimId: string, - trigger: DisputeTrigger, - originalConfidence: number, - initiatorId?: string, - metadata?: Record, - ): Promise { - // Check for existing active dispute - const existingDispute = await this.disputeRepository.findOne({ - where: { - claimId, - status: DisputeStatus.OPEN, - }, - }); - - if (existingDispute) { - throw new BadRequestException('Active dispute already exists for this claim'); + async createDispute(dto: CreateDisputeDto): Promise { + const { claimId, trigger, originalConfidence, initiatorId, metadata } = dto; + + this.validateConfidence(originalConfidence, 'originalConfidence'); + + // Single query covering both the open-duplicate check and the cooldown check + const [activeDispute, recentDispute] = await Promise.all([ + this.disputeRepository.findOne({ + where: { claimId, status: DisputeStatus.OPEN }, + }), + this.disputeRepository + .createQueryBuilder('d') + .where('d.claimId = :claimId', { claimId }) + .andWhere('d.createdAt > :cooldownTime', { + cooldownTime: this.hoursAgo(this.config.DISPUTE_COOLDOWN_HOURS), + }) + .orderBy('d.createdAt', 'DESC') + .getOne(), + ]); + + if (activeDispute) { + throw new ConflictException( + `An open dispute already exists for claim ${claimId} (dispute id: ${activeDispute.id})`, + ); } - // Check cooldown for spam prevention - const recentDispute = await this.disputeRepository - .createQueryBuilder('dispute') - .where('dispute.claimId = :claimId', { claimId }) - .andWhere('dispute.createdAt > :cooldownTime', { - cooldownTime: new Date(Date.now() - DEFAULT_CONFIG.DISPUTE_COOLDOWN_HOURS * 60 * 60 * 1000), - }) - .getOne(); - if (recentDispute) { - throw new BadRequestException('Dispute cooldown period not elapsed'); + const elapsed = Date.now() - recentDispute.createdAt.getTime(); + const remainingHours = ( + this.config.DISPUTE_COOLDOWN_HOURS - elapsed / 3_600_000 + ).toFixed(1); + throw new BadRequestException( + `Dispute cooldown active for claim ${claimId}. ${remainingHours}h remaining.`, + ); } const dispute = this.disputeRepository.create({ @@ -82,93 +187,124 @@ export class DisputeService { trigger, originalConfidence, initiatorId, - metadata: metadata || {}, + metadata: metadata ?? {}, status: DisputeStatus.OPEN, }); - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log( + `Dispute ${saved.id} created for claim ${claimId} — trigger=${trigger}, confidence=${originalConfidence}`, + ); + return saved; } + // ─── Status transitions ────────────────────────────────────────────────── + /** - * Start review process + * Transition a dispute from OPEN → REVIEWING. + * Stamps `reviewStartedAt` and persists atomically. */ async startReview(disputeId: string): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); - - if (!dispute) { - throw new NotFoundException('Dispute not found'); - } - - if (dispute.status !== DisputeStatus.OPEN) { - throw new BadRequestException('Dispute is not in OPEN status'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, [DisputeStatus.OPEN], 'start review on'); dispute.status = DisputeStatus.REVIEWING; dispute.reviewStartedAt = new Date(); - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log(`Dispute ${disputeId} moved to REVIEWING`); + return saved; } /** - * Resolve dispute with outcome + * Resolve a dispute in OPEN or REVIEWING state. + * Merges any additional metadata with the existing record. */ - async resolveDispute( - disputeId: string, - outcome: DisputeOutcome, - finalConfidence: number, - metadata?: Record, - ): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); + async resolveDispute(dto: ResolveDisputeDto): Promise { + const { disputeId, outcome, finalConfidence, metadata } = dto; - if (!dispute) { - throw new NotFoundException('Dispute not found'); - } + this.validateConfidence(finalConfidence, 'finalConfidence'); - if (![DisputeStatus.OPEN, DisputeStatus.REVIEWING].includes(dispute.status)) { - throw new BadRequestException('Dispute cannot be resolved in current status'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, RESOLVABLE_STATUSES, 'resolve'); dispute.status = DisputeStatus.RESOLVED; dispute.outcome = outcome; dispute.finalConfidence = finalConfidence; dispute.resolvedAt = new Date(); - + if (metadata) { dispute.metadata = { ...dispute.metadata, ...metadata }; } - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log( + `Dispute ${disputeId} resolved — outcome=${outcome}, finalConfidence=${finalConfidence}`, + ); + return saved; } /** - * Reject dispute (spam/invalid) + * Reject a dispute as spam or invalid. + * Only OPEN disputes may be rejected; REVIEWING disputes must be resolved. */ - async rejectDispute(disputeId: string, reason: string): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); + async rejectDispute(dto: RejectDisputeDto): Promise { + const { disputeId, reason, rejectedBy } = dto; - if (!dispute) { - throw new NotFoundException('Dispute not found'); + if (!reason?.trim()) { + throw new BadRequestException('A rejection reason is required'); } - if (dispute.status !== DisputeStatus.OPEN) { - throw new BadRequestException('Only OPEN disputes can be rejected'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, [DisputeStatus.OPEN], 'reject'); dispute.status = DisputeStatus.REJECTED; - dispute.metadata = { ...dispute.metadata, rejectionReason: reason }; dispute.resolvedAt = new Date(); + dispute.metadata = { + ...dispute.metadata, + rejectionReason: reason, + ...(rejectedBy ? { rejectedBy } : {}), + }; + + const saved = await this.disputeRepository.save(dispute); + this.logger.log(`Dispute ${disputeId} rejected — reason="${reason}"`); + return saved; + } - return this.disputeRepository.save(dispute); + /** + * Expire a single dispute that has exceeded MAX_DISPUTE_DURATION_HOURS. + * Wraps the update in a transaction to prevent double-expiry races. + */ + async expireDispute(disputeId: string): Promise { + return this.dataSource.transaction(async (manager) => { + const dispute = await manager.findOne(Dispute, { where: { id: disputeId } }); + + if (!dispute) throw new NotFoundException(`Dispute ${disputeId} not found`); + + if (!RESOLVABLE_STATUSES.includes(dispute.status)) { + throw new BadRequestException( + `Dispute ${disputeId} is in status ${dispute.status} and cannot be expired`, + ); + } + + dispute.status = DisputeStatus.EXPIRED; + dispute.resolvedAt = new Date(); + dispute.metadata = { + ...dispute.metadata, + expiredReason: `Exceeded ${this.config.MAX_DISPUTE_DURATION_HOURS}h maximum duration`, + }; + + const saved = await manager.save(dispute); + this.logger.log(`Dispute ${disputeId} expired`); + return saved; + }); } + // ─── Queries ───────────────────────────────────────────────────────────── + /** - * Get dispute by claim ID + * Fetch the most recent dispute for a given claim. + * Returns `null` when no dispute has ever been raised. */ async getDisputeByClaimId(claimId: string): Promise { return this.disputeRepository.findOne({ @@ -178,39 +314,87 @@ export class DisputeService { } /** - * Check for expired disputes + * Fetch all disputes for a claim, newest-first. */ - async getExpiredDisputes(): Promise { - const expiryTime = new Date( - Date.now() - DEFAULT_CONFIG.MAX_DISPUTE_DURATION_HOURS * 60 * 60 * 1000, - ); + async getDisputeHistoryForClaim(claimId: string): Promise { + return this.disputeRepository.find({ + where: { claimId }, + order: { createdAt: 'DESC' }, + }); + } + /** + * Return disputes whose OPEN/REVIEWING status has outlasted the configured + * maximum duration. Intended for a scheduled expiry job. + */ + async getExpiredDisputes(): Promise { return this.disputeRepository - .createQueryBuilder('dispute') - .where('dispute.status IN (:...statuses)', { - statuses: [DisputeStatus.OPEN, DisputeStatus.REVIEWING], + .createQueryBuilder('d') + .where('d.status IN (:...statuses)', { statuses: RESOLVABLE_STATUSES }) + .andWhere('d.createdAt < :expiryTime', { + expiryTime: this.hoursAgo(this.config.MAX_DISPUTE_DURATION_HOURS), }) - .andWhere('dispute.createdAt < :expiryTime', { expiryTime }) .getMany(); } /** - * Get all disputes with filters + * Paginated, filtered dispute listing. + * All filter fields are optional — omitting all returns the full set. */ - async findAll( - status?: DisputeStatus, - trigger?: DisputeTrigger, - ): Promise { - const query = this.disputeRepository.createQueryBuilder('dispute'); - - if (status) { - query.andWhere('dispute.status = :status', { status }); + async findAll(dto: FindAllDisputesDto = {}): Promise { + const { status, trigger, claimId, limit = 50, offset = 0 } = dto; + + if (limit < 1 || limit > 200) { + throw new BadRequestException('limit must be between 1 and 200'); } - if (trigger) { - query.andWhere('dispute.trigger = :trigger', { trigger }); + const qb = this.disputeRepository + .createQueryBuilder('d') + .orderBy('d.createdAt', 'DESC') + .skip(offset) + .take(limit); + + if (status) qb.andWhere('d.status = :status', { status }); + if (trigger) qb.andWhere('d.trigger = :trigger', { trigger }); + if (claimId) qb.andWhere('d.claimId = :claimId', { claimId }); + + const [items, total] = await qb.getManyAndCount(); + return { items, total, limit, offset }; + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private async findDisputeOrThrow(disputeId: string): Promise { + const dispute = await this.disputeRepository.findOne({ + where: { id: disputeId }, + }); + if (!dispute) { + throw new NotFoundException(`Dispute with ID ${disputeId} not found`); + } + return dispute; + } + + private assertStatus( + dispute: Dispute, + allowed: DisputeStatus[], + action: string, + ): void { + if (!allowed.includes(dispute.status)) { + throw new BadRequestException( + `Cannot ${action} dispute ${dispute.id}: current status is ${dispute.status}, expected one of [${allowed.join(', ')}]`, + ); } + } + + private validateConfidence(value: number, field: string): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new BadRequestException( + `${field} must be a finite number between 0 and 1, received ${value}`, + ); + } + } - return query.orderBy('dispute.createdAt', 'DESC').getMany(); + private hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 3_600_000); } } \ No newline at end of file diff --git a/src/identity/identity.service.ts b/src/identity/identity.service.ts index 600244a..d3afd70 100644 --- a/src/identity/identity.service.ts +++ b/src/identity/identity.service.ts @@ -1,6 +1,53 @@ -import { BadRequestException, Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + ConflictException, + NotFoundException, + Logger, + ForbiddenException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { LinkWalletDto } from './dto/link-wallet.dto'; +import { verifyMessage, getAddress } from 'ethers'; +import { Prisma, User, Wallet } from '@prisma/client'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type UserWithWallets = User & { wallets: Wallet[] }; + +export interface WalletIdentifier { + address: string; + chain: string; +} + +export interface LinkWalletResult { + wallet: Wallet; + alreadyLinked: boolean; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Minimum number of wallets a user must retain. Set to 0 to allow full unlink. */ +const MIN_WALLETS = 1; + +// ─── Service ────────────────────────────────────────────────────────────────── + +@Injectable() +export class IdentityService { + private readonly logger = new Logger(IdentityService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ─── User ────────────────────────────────────────────────────────────── + + /** + * Create a new user with no initial wallets. + * The caller is responsible for linking at least one wallet afterward. + */ + async createUser(): Promise { + const user = await this.prisma.user.create({ data: {} }); + this.logger.log(`User created: ${user.id}`); + return user; import { verifyMessage } from 'ethers'; import { AuditTrailService } from '../audit/services/audit-trail.service'; import { AuditActionType, AuditEntityType } from '../audit/entities/audit-log.entity'; @@ -28,18 +75,86 @@ export class IdentityService { }); } - async getUser(id: string) { + /** + * Fetch a user by ID, including their linked wallets. + * Throws `NotFoundException` if no user exists with that ID. + */ + async getUser(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, include: { wallets: true }, }); - if (!user) throw new NotFoundException('User not found'); + if (!user) throw new NotFoundException(`User ${id} not found`); return user; } - async linkWallet(userId: string, dto: LinkWalletDto) { + // ─── Link wallet ─────────────────────────────────────────────────────── + + /** + * Link an EVM wallet to a user after verifying the provided signature. + * + * Rules enforced: + * - The signature must recover to the claimed address (EIP-191). + * - An address may not be linked to more than one user (cross-chain included). + * - The (address, chain) pair must be unique per the schema constraint. + * - Returns `alreadyLinked: true` when the exact pair is already on this user + * so the caller can distinguish a no-op from a new link. + * + * @throws BadRequestException on signature format/mismatch errors. + * @throws ConflictException when the address belongs to a different user. + * @throws NotFoundException when the user does not exist. + */ + async linkWallet( + userId: string, + dto: LinkWalletDto, + ): Promise { const { address, chain, signature, message } = dto; + // ── 1. Normalise and verify signature ────────────────────────────── + const normalizedAddress = this.normalizeAddress(address); + this.verifySignature(message, signature, normalizedAddress); + + // ── 2. Check global address ownership ───────────────────────────── + const existingWallet = await this.prisma.wallet.findFirst({ + where: { address: normalizedAddress }, + }); + + if (existingWallet) { + if (existingWallet.userId !== userId) { + throw new ConflictException( + `Address ${normalizedAddress} is already linked to another account`, + ); + } + // Same user + same chain → idempotent no-op + if (existingWallet.chain === chain) { + this.logger.debug( + `Wallet ${normalizedAddress}/${chain} already linked to user ${userId} — no-op`, + ); + return { wallet: existingWallet, alreadyLinked: true }; + } + // Same user, different chain → fall through to create + } + + // ── 3. Ensure user exists before writing ─────────────────────────── + await this.findUserOrThrow(userId); + + // ── 4. Create wallet — handle schema-level unique violation ──────── + try { + const wallet = await this.prisma.wallet.create({ + data: { address: normalizedAddress, chain, userId }, + }); + this.logger.log( + `Wallet ${normalizedAddress} (${chain}) linked to user ${userId}`, + ); + return { wallet, alreadyLinked: false }; + } catch (err) { + if (this.isPrismaUniqueViolation(err)) { + throw new ConflictException( + `Wallet ${normalizedAddress} on chain ${chain} is already linked`, + ); + } + throw err; + } // 1. Verify Signature (outside transaction - pure computation) let recoveredAddress: string; try { @@ -84,31 +199,53 @@ export class IdentityService { }); } - async unlinkWallet(userId: string, address: string, chain: string) { + // ─── Unlink wallet ───────────────────────────────────────────────────── + + /** + * Remove a wallet from a user's account. + * + * Enforces `MIN_WALLETS`: if the user would drop below the minimum number + * of linked wallets, the request is rejected. Set `MIN_WALLETS = 0` to + * allow full unlinking. + * + * @throws NotFoundException if the wallet does not exist. + * @throws ForbiddenException if the wallet belongs to a different user. + * @throws BadRequestException if unlinking would violate the minimum wallet count. + */ + async unlinkWallet( + userId: string, + identifier: WalletIdentifier, + ): Promise { + const { address, chain } = identifier; + const normalizedAddress = this.normalizeAddress(address); + const wallet = await this.prisma.wallet.findUnique({ - where: { - address_chain: { - address, - chain, - }, - }, + where: { address_chain: { address: normalizedAddress, chain } }, }); if (!wallet) { - throw new NotFoundException('Wallet not found'); + throw new NotFoundException( + `Wallet ${normalizedAddress} on chain ${chain} not found`, + ); } if (wallet.userId !== userId) { - throw new BadRequestException('Wallet does not belong to this user'); + throw new ForbiddenException( + `Wallet ${normalizedAddress} does not belong to user ${userId}`, + ); } - // Safeguard: Maybe check if it's the last wallet? - // "Support unlinking with safeguards" - // Let's count wallets. - const count = await this.prisma.wallet.count({ - where: { userId }, - }); + if (MIN_WALLETS > 0) { + const count = await this.prisma.wallet.count({ where: { userId } }); + if (count <= MIN_WALLETS) { + throw new BadRequestException( + `Cannot unlink wallet — users must retain at least ${MIN_WALLETS} linked wallet(s)`, + ); + } + } + const deleted = await this.prisma.wallet.delete({ + where: { address_chain: { address: normalizedAddress, chain } }, // if (count <= 1) throw new BadRequestException('Cannot unlink the last wallet.'); // For now, I'll allow unlinking all, as the user might want to delete their identity or switch completely. // But I'll leave a comment. @@ -130,13 +267,90 @@ export class IdentityService { }, }, }); + + this.logger.log( + `Wallet ${normalizedAddress} (${chain}) unlinked from user ${userId}`, + ); + return deleted; } - async findUserByAddress(address: string) { + // ─── Queries ─────────────────────────────────────────────────────────── + + /** + * Look up the user who owns a given wallet address (any chain). + * Returns `null` when no wallet with that address is found. + */ + async findUserByAddress(address: string): Promise { + const normalized = this.normalizeAddress(address); const wallet = await this.prisma.wallet.findFirst({ - where: { address }, + where: { address: normalized }, include: { user: true }, }); - return wallet?.user || null; + return wallet?.user ?? null; } -} + + /** + * Return all wallets linked to a user, optionally filtered by chain. + */ + async getWalletsForUser(userId: string, chain?: string): Promise { + await this.findUserOrThrow(userId); + return this.prisma.wallet.findMany({ + where: { userId, ...(chain ? { chain } : {}) }, + orderBy: { createdAt: 'asc' }, + }); + } + + // ─── Private helpers ─────────────────────────────────────────────────── + + /** + * Normalise an EVM address to EIP-55 checksum form. + * Throws `BadRequestException` on malformed input. + */ + private normalizeAddress(address: string): string { + try { + return getAddress(address); + } catch { + throw new BadRequestException( + `Invalid EVM address: "${address}"`, + ); + } + } + + /** + * Recover the signer from an EIP-191 signed message and assert it matches + * the claimed address. + */ + private verifySignature( + message: string, + signature: string, + expectedAddress: string, + ): void { + let recovered: string; + try { + recovered = verifyMessage(message, signature); + } catch { + throw new BadRequestException( + 'Signature could not be parsed — ensure it is a valid EIP-191 hex signature', + ); + } + + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new BadRequestException( + `Signature verification failed: recovered ${recovered}, expected ${expectedAddress}`, + ); + } + } + + private async findUserOrThrow(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException(`User ${userId} not found`); + return user; + } + + private isPrismaUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ); + } +} \ No newline at end of file diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index a59c65b..e268556 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -1,7 +1,12 @@ -import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { RedisService } from '../redis/redis.service'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, IsNull, Not, Repository } from 'typeorm'; import { Stake } from '../staking/entities/stake.entity'; import { Wallet } from '../entities/wallet.entity'; import { Claim } from '../claims/entities/claim.entity'; @@ -12,11 +17,38 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { SybilResistanceService } from '../sybil-resistance/sybil-resistance.service'; -/** - * JobsService - * - Placeholder for scheduled jobs (scores, reputation) - * - Awaiting bullmq dependency resolution - */ +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SCORE_BATCH_SIZE = 50; +const REPUTATION_BATCH_SIZE = 100; + +/** Confidence threshold (0–100 scale from AggregationService) above which a + * claim is considered resolved. Matches original > 50 logic. */ +const FINALIZATION_THRESHOLD = 50; + +/** Normalises AggregationService confidence (0–100) to the stored 0–1 field. */ +const CONFIDENCE_SCALE = 100; + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface AggregationVerification { + id: string; + claimId: string; + userId: string | null; + verdict: 'TRUE' | 'FALSE'; + stakeAmount: number; + reputationWeight: number; + createdAt: Date; +} + +interface BatchResult { + processed: number; + updated: number; + errors: number; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class JobsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(JobsService.name); @@ -32,6 +64,24 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { @InjectRepository(User) private readonly userRepo: Repository, private readonly claimsCache: ClaimsCache, + private readonly aggregationService: AggregationService, + ) {} + + // ─── Lifecycle ───────────────────────────────────────────────────────── + + async onModuleInit(): Promise { + this.logger.log('JobsService initialized — BullMQ integration pending'); + } + + async onModuleDestroy(): Promise { + this.logger.log('JobsService shutting down'); + } + + // ─── Public job entry-points ──────────────────────────────────────────── + // These will become @Process() handlers once BullMQ is wired in. + + async runComputeScores(): Promise { + return this.computeScores(); @InjectQueue('jobs-queue') private readonly jobsQueue: Queue, private readonly sybilResistanceService: SybilResistanceService, private readonly aggregationService?: AggregationService, @@ -81,10 +131,20 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { } } - async onModuleDestroy() { - this.logger.log('JobsService shutdown'); + async runComputeReputation(): Promise { + return this.computeReputation(); } + // ─── computeScores ────────────────────────────────────────────────────── + + /** + * Process a batch of unfinalized claims, computing an aggregated confidence + * score from their stakes and marking high-confidence claims as resolved. + * + * N+1 pattern eliminated: wallets and users are bulk-fetched per claim batch + * rather than one DB round-trip per stake. + */ + private async computeScores(): Promise { async cleanupSybilHistory(): Promise { this.logger.debug('cleanupSybilHistory: starting'); const count = await this.sybilResistanceService.cleanupScoreHistory(); @@ -94,46 +154,71 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { async computeScores() { this.logger.debug('computeScores: starting'); + const result: BatchResult = { processed: 0, updated: 0, errors: 0 }; + + const claims = await this.claimRepo.find({ + where: { finalized: false }, + take: SCORE_BATCH_SIZE, + }); + + if (claims.length === 0) { + this.logger.debug('computeScores: no unfinalized claims found'); + return result; + } + + // Bulk-load all stakes for this batch in one query + const claimIds = claims.map((c) => c.id); + const allStakes = await this.stakeRepo.find({ + where: { claimId: In(claimIds) }, + }); + + // Group stakes by claimId for O(1) lookup + const stakesByClaimId = groupBy(allStakes, (s) => s.claimId); + + // Bulk-load wallets and users referenced in this batch + const walletAddresses = [...new Set(allStakes.map((s) => s.walletAddress))]; + const wallets = walletAddresses.length + ? await this.walletRepo.find({ where: { address: In(walletAddresses) } }) + : []; - // Process claims in small batches - const batchSize = 50; - const claims = await this.claimRepo.find({ where: { finalized: false }, take: batchSize }); + const walletByAddress = indexBy(wallets, (w) => w.address); + const userIds = [...new Set(wallets.map((w) => w.userId).filter(Boolean))]; + const users = userIds.length + ? await this.userRepo.find({ where: { id: In(userIds) } }) + : []; + + const userById = indexBy(users, (u) => u.id); + + // Process each claim for (const claim of claims) { + result.processed++; try { - const stakes = await this.stakeRepo.find({ where: { claimId: claim.id } }); + const stakes = stakesByClaimId.get(claim.id) ?? []; - if (!stakes || stakes.length === 0) { - this.logger.debug(`No stakes for claim ${claim.id}, marking inconclusive`); + if (stakes.length === 0) { + this.logger.debug(`Claim ${claim.id}: no stakes — marking inconclusive`); claim.confidenceScore = 0; await this.claimRepo.save(claim); + result.updated++; continue; } - // Build aggregation compatible verifications - const verifications = [] as any[]; - - for (const s of stakes) { - const wallet = await this.walletRepo.findOneBy({ address: s.walletAddress }); - const user = wallet ? await this.userRepo.findOneBy({ id: wallet.userId }) : null; - - const stakeAmount = typeof (s as any).amount === 'string' ? parseFloat((s as any).amount) : Number((s as any).amount || 0); - const reputationWeight = user ? Math.max(0, Math.min(1, (user.reputation || 0) / 100)) : 0; - - verifications.push({ - id: (s as any).id, - claimId: claim.id, - userId: user?.id || null, - verdict: 'TRUE', - stakeAmount, - reputationWeight, - createdAt: (s as any).updatedAt || new Date(), - }); - } + const verifications = this.buildVerifications( + claim.id, + stakes, + walletByAddress, + userById, + ); - const agg = this.aggregationService ?? new AggregationService(); - const result = agg.aggregate(claim.id, verifications); + const agg = this.aggregationService.aggregate(claim.id, verifications); + const wasFinalized = claim.finalized; + claim.confidenceScore = agg.confidence / CONFIDENCE_SCALE; + + if (agg.confidence > FINALIZATION_THRESHOLD) { + claim.finalized = true; + claim.resolvedVerdict = agg.status === 'VERIFIED_TRUE'; const updateFields: Partial = { confidenceScore: result.confidence / 100, }; @@ -153,13 +238,28 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { } await this.claimsCache.invalidateClaim(claim.id); + result.updated++; + + this.logger.log( + `Claim ${claim.id}: confidence=${claim.confidenceScore.toFixed(4)}` + + (claim.finalized && !wasFinalized + ? `, finalized → verdict=${claim.resolvedVerdict}` + : ''), + ); this.logger.log(`Updated claim ${claim.id} confidence=${updateFields.confidenceScore}`); } catch (err) { - this.logger.error(`Error processing claim ${claim.id}: ${err?.message || err}`); + result.errors++; + this.logger.error( + `computeScores: error on claim ${claim.id}`, + err instanceof Error ? err.stack : String(err), + ); } } - this.logger.debug('computeScores: finished'); + this.logger.debug( + `computeScores: finished — processed=${result.processed} updated=${result.updated} errors=${result.errors}`, + ); + return result; } async computeReputation() { @@ -180,55 +280,160 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { private async computeReputation() { this.logger.debug('computeReputation: starting'); + const result: BatchResult = { processed: 0, updated: 0, errors: 0 }; - // Process users in batches - const batchSize = 100; - const users = await this.userRepo.find({ take: batchSize }); + const users = await this.userRepo.find({ take: REPUTATION_BATCH_SIZE }); + if (users.length === 0) { + this.logger.debug('computeReputation: no users found'); + return result; + } - for (const user of users) { - try { - // Find wallets for user - const wallets = await this.walletRepo.find({ where: { userId: user.id } }); - if (!wallets || wallets.length === 0) continue; + const userIds = users.map((u) => u.id); + + // Bulk-load wallets for all users in this batch + const wallets = await this.walletRepo.find({ + where: { userId: In(userIds) }, + }); - const walletAddresses = wallets.map((w) => w.address); + const walletsByUserId = groupBy(wallets, (w) => w.userId); + const allAddresses = wallets.map((w) => w.address); - // Find stakes by these wallets on claims that are finalized - const stakes = await this.stakeRepo - .createQueryBuilder('s') - .where('s.walletAddress IN (:...addrs)', { addrs: walletAddresses }) - .getMany(); + if (allAddresses.length === 0) { + this.logger.debug('computeReputation: no wallets found for batch'); + return result; + } - if (!stakes || stakes.length === 0) continue; + // Bulk-load all stakes for these wallets + const allStakes = await this.stakeRepo + .createQueryBuilder('s') + .where('s.walletAddress IN (:...addrs)', { addrs: allAddresses }) + .getMany(); + + const stakesByWalletAddress = groupBy(allStakes, (s) => s.walletAddress); + + // Bulk-load only finalized claims with a non-null verdict + const stakedClaimIds = [...new Set(allStakes.map((s) => s.claimId))]; + const finalizedClaims = + stakedClaimIds.length > 0 + ? await this.claimRepo.find({ + where: { + id: In(stakedClaimIds), + finalized: true, + resolvedVerdict: Not(IsNull()), + }, + }) + : []; + + const claimById = indexBy(finalizedClaims, (c) => c.id); + + // Process each user + for (const user of users) { + result.processed++; + try { + const userWallets = walletsByUserId.get(user.id) ?? []; + if (userWallets.length === 0) continue; let claimsVotedOn = 0; let claimsCorrect = 0; - for (const s of stakes) { - const claim = await this.claimRepo.findOneBy({ id: s.claimId }); - if (!claim || !claim.finalized || claim.resolvedVerdict === null) continue; - - claimsVotedOn++; - // We assume stake implies voting TRUE - const votedTrue = true; - if (votedTrue === Boolean(claim.resolvedVerdict)) claimsCorrect++; + for (const wallet of userWallets) { + const stakes = stakesByWalletAddress.get(wallet.address) ?? []; + for (const stake of stakes) { + const claim = claimById.get(stake.claimId); + if (!claim) continue; // not finalized or no verdict + + claimsVotedOn++; + if (this.deriveVotedTrue(stake) === Boolean(claim.resolvedVerdict)) { + claimsCorrect++; + } + } } if (claimsVotedOn === 0) continue; - const accuracy = claimsCorrect / claimsVotedOn; - const newReputation = Math.round(accuracy * 100); + const newReputation = Math.round((claimsCorrect / claimsVotedOn) * 100); if (user.reputation !== newReputation) { user.reputation = newReputation; await this.userRepo.save(user); - this.logger.log(`Updated reputation for user ${user.id}: ${newReputation}`); + result.updated++; + this.logger.log( + `User ${user.id}: reputation ${user.reputation} → ${newReputation}`, + ); } } catch (err) { - this.logger.error(`Error computing reputation for user ${user.id}: ${err?.message || err}`); + result.errors++; + this.logger.error( + `computeReputation: error on user ${user.id}`, + err instanceof Error ? err.stack : String(err), + ); } } - this.logger.debug('computeReputation: finished'); + this.logger.debug( + `computeReputation: finished — processed=${result.processed} updated=${result.updated} errors=${result.errors}`, + ); + return result; + } + + // ─── Private helpers ──────────────────────────────────────────────────── + + private buildVerifications( + claimId: string, + stakes: Stake[], + walletByAddress: Map, + userById: Map, + ): AggregationVerification[] { + return stakes.map((stake) => { + const wallet = walletByAddress.get(stake.walletAddress); + const user = wallet ? userById.get(wallet.userId) : null; + + const stakeAmount = + typeof (stake as any).amount === 'string' + ? parseFloat((stake as any).amount) + : Number((stake as any).amount ?? 0); + + const reputationWeight = user + ? Math.max(0, Math.min(1, (user.reputation ?? 0) / 100)) + : 0; + + return { + id: stake.id, + claimId, + userId: user?.id ?? null, + verdict: 'TRUE', + stakeAmount, + reputationWeight, + createdAt: (stake as any).updatedAt ?? new Date(), + }; + }); + } + + /** + * Derives whether a stake represents a TRUE vote. + * Currently all stakes are treated as TRUE; extend this once stakes carry + * an explicit `verdict` field. + */ + private deriveVotedTrue(_stake: Stake): boolean { + return true; + } +} + +// ─── Utility functions ──────────────────────────────────────────────────────── + +function groupBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map(); + for (const item of items) { + const key = keyFn(item); + const group = map.get(key); + if (group) group.push(item); + else map.set(key, [item]); } + return map; } + +function indexBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map(); + for (const item of items) map.set(keyFn(item), item); + return map; +} \ No newline at end of file