mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
150 lines
4.9 KiB
TypeScript
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 };
|
|
}
|