| 1 | import type { DigraphAggregation, ComparisonResult, PerDigraphComparison } from './types'; |
| 2 | |
| 3 | const METRIC_KEYS = [ |
| 4 | 'holdTime1', |
| 5 | 'holdTime2', |
| 6 | 'pressPress', |
| 7 | 'releaseRelease', |
| 8 | 'pressRelease', |
| 9 | 'releasePress', |
| 10 | ] as const; |
| 11 | |
| 12 | const MAX_METRIC_DISTANCE = 3.0; |
| 13 | const MIN_STD_FALLBACK = 15.0; |
| 14 | |
| 15 | export function compareSession( |
| 16 | sessionAggs: DigraphAggregation[], |
| 17 | profileAggs: DigraphAggregation[], |
| 18 | ): ComparisonResult { |
| 19 | const profileMap = new Map<string, DigraphAggregation>(); |
| 20 | for (const a of profileAggs) { |
| 21 | profileMap.set(a.normalizedKeys, a); |
| 22 | } |
| 23 | |
| 24 | const perDigraph: PerDigraphComparison[] = []; |
| 25 | let totalDistance = 0; |
| 26 | let metricCount = 0; |
| 27 | |
| 28 | for (const sessionAgg of sessionAggs) { |
| 29 | const profileAgg = profileMap.get(sessionAgg.normalizedKeys); |
| 30 | if (!profileAgg) continue; |
| 31 | |
| 32 | let digraphDistance = 0; |
| 33 | let digraphMetrics = 0; |
| 34 | |
| 35 | for (const key of METRIC_KEYS) { |
| 36 | const sessionMean = sessionAgg[key].mean; |
| 37 | const profileMean = profileAgg[key].mean; |
| 38 | const profileStd = profileAgg[key].std; |
| 39 | |
| 40 | const divisor = Math.max(profileStd, MIN_STD_FALLBACK); |
| 41 | const distance = Math.min( |
| 42 | Math.abs(sessionMean - profileMean) / divisor, |
| 43 | MAX_METRIC_DISTANCE, |
| 44 | ); |
| 45 | |
| 46 | digraphDistance += distance; |
| 47 | digraphMetrics++; |
| 48 | } |
| 49 | |
| 50 | const avgDistance = digraphMetrics > 0 ? digraphDistance / digraphMetrics : 0; |
| 51 | const matchPercent = Math.max(0, Math.round(100 * (1 - avgDistance / 3.0))); |
| 52 | |
| 53 | perDigraph.push({ |
| 54 | normalizedKeys: sessionAgg.normalizedKeys, |
| 55 | distance: Math.round(avgDistance * 100) / 100, |
| 56 | matchPercent, |
| 57 | }); |
| 58 | |
| 59 | totalDistance += digraphDistance; |
| 60 | metricCount += digraphMetrics; |
| 61 | } |
| 62 | |
| 63 | const sharedCount = perDigraph.length; |
| 64 | const overallDistance = metricCount > 0 |
| 65 | ? Math.round((totalDistance / metricCount) * 100) / 100 |
| 66 | : 0; |
| 67 | const similarityPercent = Math.max(0, Math.round(100 * (1 - overallDistance / 3.0))); |
| 68 | |
| 69 | let confidence: ComparisonResult['confidence']; |
| 70 | if (sharedCount >= 15) confidence = 'high'; |
| 71 | else if (sharedCount >= 10) confidence = 'medium'; |
| 72 | else if (sharedCount >= 5) confidence = 'low'; |
| 73 | else confidence = 'insufficient'; |
| 74 | |
| 75 | // Sort by distance descending (worst matches first) |
| 76 | perDigraph.sort((a, b) => b.distance - a.distance); |
| 77 | |
| 78 | return { |
| 79 | overallDistance, |
| 80 | similarityPercent, |
| 81 | confidence, |
| 82 | sharedCount, |
| 83 | perDigraph, |
| 84 | }; |
| 85 | } |