Files
SubMiner/stats/src/lib/session-density.ts
T

150 lines
4.9 KiB
TypeScript

import type { KnownWordsTimelinePoint } from '../hooks/useSessions';
import { readCardsMinedDelta } from './session-events';
import { EventType } from '../types/stats';
import type { SessionEvent, SessionTimelinePoint } from '../types/stats';
/** One time slice of a session: words read, lookups made, and cards mined inside it. */
export interface SessionDensityBucket {
index: number;
startMs: number;
endMs: number;
words: number;
knownWords: number;
unknownWords: number;
lookups: number;
cards: number;
}
export interface SessionDensity {
bucketMs: number;
startMs: number;
endMs: number;
/** True when the known-words timeline had data, so known/unknown splits are meaningful. */
hasKnownWords: boolean;
buckets: SessionDensityBucket[];
}
const BUCKET_SIZE_CANDIDATES_MS = [
10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000,
];
export const MAX_DENSITY_BUCKETS = 60;
/**
* Smallest candidate bucket size that keeps the whole span within maxBuckets.
* Spans too long for every candidate get whole-minute buckets sized to fit.
*/
export function pickDensityBucketMs(spanMs: number, maxBuckets = MAX_DENSITY_BUCKETS): number {
for (const candidate of BUCKET_SIZE_CANDIDATES_MS) {
if (Math.ceil(spanMs / candidate) <= maxBuckets) return candidate;
}
return Math.ceil(spanMs / maxBuckets / 60_000) * 60_000;
}
export function formatBucketSize(bucketMs: number): string {
if (bucketMs < 60_000) return `${Math.round(bucketMs / 1000)}s`;
const minutes = Math.round(bucketMs / 60_000);
return `${minutes}m`;
}
type KnownWordCounts = { knownWordsSeen: number; totalWordsSeen: number };
/** Cumulative known/total counts at a lines-seen checkpoint, using the nearest earlier point. */
function lookupKnownWordCounts(
sortedPoints: KnownWordsTimelinePoint[],
linesSeen: number,
): KnownWordCounts {
let best: KnownWordCounts = { knownWordsSeen: 0, totalWordsSeen: 0 };
for (const point of sortedPoints) {
if (point.linesSeen > linesSeen) break;
best = point;
}
return best;
}
/**
* Turn cumulative telemetry samples into per-bucket deltas so the chart shows how
* dense the dialogue was over the session instead of a monotonically rising total.
*/
export function buildSessionDensity(
timeline: SessionTimelinePoint[],
knownWordsTimeline: KnownWordsTimelinePoint[],
events: SessionEvent[],
): SessionDensity {
const samples = [...timeline].sort((a, b) => a.sampleMs - b.sampleMs);
if (samples.length === 0) {
return { bucketMs: 0, startMs: 0, endMs: 0, hasKnownWords: false, buckets: [] };
}
const knownPoints = [...knownWordsTimeline].sort((a, b) => a.linesSeen - b.linesSeen);
const hasKnownWords = knownPoints.length > 0;
const startMs = samples[0]!.sampleMs;
const lastSampleMs = samples[samples.length - 1]!.sampleMs;
const spanMs = Math.max(1, lastSampleMs - startMs);
const bucketMs = pickDensityBucketMs(spanMs);
const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
const endMs = startMs + bucketCount * bucketMs;
const buckets: SessionDensityBucket[] = [];
let sampleIndex = -1;
let prevWords = 0;
let prevKnown = 0;
for (let index = 0; index < bucketCount; index += 1) {
const bucketStart = startMs + index * bucketMs;
const bucketEnd = bucketStart + bucketMs;
// The last bucket also owns a sample sitting exactly on the session's end edge.
const isLastBucket = index === bucketCount - 1;
while (
sampleIndex + 1 < samples.length &&
(isLastBucket || samples[sampleIndex + 1]!.sampleMs < bucketEnd)
) {
sampleIndex += 1;
}
let cumulativeWords = prevWords;
let cumulativeKnown = prevKnown;
if (sampleIndex >= 0) {
const sample = samples[sampleIndex]!;
if (hasKnownWords) {
const counts = lookupKnownWordCounts(knownPoints, sample.linesSeen);
cumulativeWords = counts.totalWordsSeen;
cumulativeKnown = Math.min(counts.knownWordsSeen, counts.totalWordsSeen);
} else {
cumulativeWords = sample.tokensSeen;
}
}
const words = Math.max(0, cumulativeWords - prevWords);
const knownWords = Math.min(words, Math.max(0, cumulativeKnown - prevKnown));
prevWords = Math.max(prevWords, cumulativeWords);
prevKnown = Math.max(prevKnown, cumulativeKnown);
buckets.push({
index,
startMs: bucketStart,
endMs: bucketEnd,
words,
knownWords,
unknownWords: words - knownWords,
lookups: 0,
cards: 0,
});
}
for (const event of events) {
if (event.eventType !== EventType.YOMITAN_LOOKUP && event.eventType !== EventType.CARD_MINED) {
continue;
}
const rawIndex = Math.floor((event.tsMs - startMs) / bucketMs);
const bucket = buckets[Math.max(0, Math.min(bucketCount - 1, rawIndex))]!;
if (event.eventType === EventType.YOMITAN_LOOKUP) {
bucket.lookups += 1;
} else {
bucket.cards += readCardsMinedDelta(event);
}
}
return { bucketMs, startMs, endMs, hasKnownWords, buckets };
}