@@ -4,13 +4,17 @@ import { dirname, join } from "node:path";
44import {
55 convertFiveHourPercentToPlusWeeklyUnits ,
66 convertOneWeekPercentToPlusWeeklyUnits ,
7+ resolveFiveHourWindowsPerWeek ,
78} from "../plan-quota-profile.js" ;
89
910const WATCH_HISTORY_FILE_NAME = "watch-quota-history.jsonl" ;
1011const WATCH_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 ;
1112const WATCH_HISTORY_KEEPALIVE_MS = 60 * 1000 ;
1213const WATCH_HISTORY_WINDOW_MS = 60 * 60 * 1000 ;
1314const WATCH_HISTORY_RESET_AT_TOLERANCE_MS = 60 * 1000 ;
15+ const WATCH_HISTORY_RATIO_DIAGNOSTIC_WINDOW_MS = 24 * 60 * 60 * 1000 ;
16+ const WATCH_HISTORY_RATIO_WARNING_RELATIVE_DELTA = 0.2 ;
17+ const WATCH_HISTORY_RATIO_WARNING_MIN_SAMPLES = 3 ;
1418
1519export interface WatchHistoryWindowSnapshot {
1620 used_percent : number ;
@@ -69,6 +73,18 @@ export interface WatchHistoryStore {
6973export type WatchQuotaHistoryRecord = WatchHistoryRecord ;
7074export type WatchEtaContext = WatchHistoryEtaContext ;
7175
76+ export interface WatchHistoryObservedRatioDiagnostic {
77+ dimension : "bucket" | "plan" ;
78+ key : string ;
79+ sample_count : number ;
80+ observed_mean_raw_ratio : number ;
81+ observed_weighted_raw_ratio : number ;
82+ variance : number ;
83+ expected_raw_ratio : number | null ;
84+ relative_delta : number | null ;
85+ warning : boolean ;
86+ }
87+
7288function roundToTwo ( value : number ) : number {
7389 return Number ( value . toFixed ( 2 ) ) ;
7490}
@@ -192,6 +208,11 @@ function isInsideRateWindow(recordedAt: string, now: Date): boolean {
192208 return now . getTime ( ) - recordedAtMs <= WATCH_HISTORY_WINDOW_MS ;
193209}
194210
211+ function isInsideObservedRatioWindow ( recordedAt : string , now : Date ) : boolean {
212+ const recordedAtMs = Date . parse ( recordedAt ) ;
213+ return now . getTime ( ) - recordedAtMs <= WATCH_HISTORY_RATIO_DIAGNOSTIC_WINDOW_MS ;
214+ }
215+
195216function formatRecord ( record : WatchHistoryRecord ) : string {
196217 return `${ JSON . stringify ( record ) } \n` ;
197218}
@@ -450,6 +471,184 @@ function computeSegmentDelta(
450471 return scoreDeltaForSegment ( start , end , end . plan_type ?? start . plan_type ) ;
451472}
452473
474+ interface WatchHistorySegment {
475+ start : WatchHistoryRecord ;
476+ end : WatchHistoryRecord ;
477+ elapsedHours : number ;
478+ }
479+
480+ interface WatchHistorySegmentMetrics extends WatchHistorySegment {
481+ delta_5h_raw : number | null ;
482+ delta_1w_raw : number | null ;
483+ delta_5h_eq_1w : number | null ;
484+ delta_1w_eq : number | null ;
485+ segment_delta_1w_units : number | null ;
486+ }
487+
488+ function collectContinuousSegments ( records : WatchHistoryRecord [ ] ) : WatchHistorySegment [ ] {
489+ if ( records . length < 2 ) {
490+ return [ ] ;
491+ }
492+
493+ const segments : WatchHistorySegment [ ] = [ ] ;
494+ let segmentStart = records [ 0 ] ?? null ;
495+ let segmentEnd = segmentStart ;
496+
497+ const appendSegment = ( start : WatchHistoryRecord | null , end : WatchHistoryRecord | null ) => {
498+ if ( ! start || ! end || start === end ) {
499+ return ;
500+ }
501+
502+ const elapsedHours = ( Date . parse ( end . recorded_at ) - Date . parse ( start . recorded_at ) ) / 3_600_000 ;
503+ if ( elapsedHours <= 0 ) {
504+ return ;
505+ }
506+
507+ segments . push ( { start, end, elapsedHours } ) ;
508+ } ;
509+
510+ for ( let index = 1 ; index < records . length ; index += 1 ) {
511+ const right = records [ index ] ;
512+ const left = segmentEnd ;
513+ if ( ! left || ! right ) {
514+ continue ;
515+ }
516+
517+ const ageMs = Date . parse ( right . recorded_at ) - Date . parse ( left . recorded_at ) ;
518+ if (
519+ ageMs > 0 &&
520+ ageMs <= WATCH_HISTORY_WINDOW_MS &&
521+ isSameRateSegment ( left , right )
522+ ) {
523+ segmentEnd = right ;
524+ continue ;
525+ }
526+
527+ appendSegment ( segmentStart , segmentEnd ) ;
528+ segmentStart = right ;
529+ segmentEnd = right ;
530+ }
531+
532+ appendSegment ( segmentStart , segmentEnd ) ;
533+ return segments ;
534+ }
535+
536+ function toSegmentMetrics ( segment : WatchHistorySegment ) : WatchHistorySegmentMetrics {
537+ const planType = segment . end . plan_type ?? segment . start . plan_type ;
538+ const delta5hRaw = deltaForContinuousWindow ( segment . start . five_hour , segment . end . five_hour ) ;
539+ const delta1wRaw = deltaForContinuousWindow ( segment . start . one_week , segment . end . one_week ) ;
540+ const delta5hEq1w =
541+ delta5hRaw === null ? null : convertFiveHourPercentToWeeklyEquivalent ( delta5hRaw , planType ) ;
542+ const delta1wEq =
543+ delta1wRaw === null ? null : convertOneWeekPercentToPlusWeeklyUnits ( delta1wRaw , planType ) ;
544+ const validDeltas = [ delta5hEq1w , delta1wEq ] . filter (
545+ ( value ) : value is number => typeof value === "number" ,
546+ ) ;
547+
548+ return {
549+ ...segment ,
550+ delta_5h_raw : delta5hRaw ,
551+ delta_1w_raw : delta1wRaw ,
552+ delta_5h_eq_1w : delta5hEq1w ,
553+ delta_1w_eq : delta1wEq ,
554+ segment_delta_1w_units : validDeltas . length === 0 ? null : Math . max ( ...validDeltas ) ,
555+ } ;
556+ }
557+
558+ function resolveObservedRatioBucket ( accountName : string ) : string | null {
559+ const normalized = accountName . trim ( ) . toLowerCase ( ) ;
560+ if ( normalized . startsWith ( "plus" ) ) {
561+ return "plus" ;
562+ }
563+ if ( normalized . startsWith ( "team" ) ) {
564+ return "team" ;
565+ }
566+ return null ;
567+ }
568+
569+ function buildObservedRatioDiagnostic (
570+ dimension : "bucket" | "plan" ,
571+ key : string ,
572+ metrics : WatchHistorySegmentMetrics [ ] ,
573+ ) : WatchHistoryObservedRatioDiagnostic {
574+ const ratios = metrics . map ( ( segment ) => ( segment . delta_5h_raw ?? 0 ) / ( segment . delta_1w_raw ?? 1 ) ) ;
575+ const observedMean = ratios . reduce ( ( sum , value ) => sum + value , 0 ) / ratios . length ;
576+ const variance =
577+ ratios . reduce ( ( sum , value ) => sum + ( value - observedMean ) ** 2 , 0 ) / ratios . length ;
578+ const total5h = metrics . reduce ( ( sum , segment ) => sum + ( segment . delta_5h_raw ?? 0 ) , 0 ) ;
579+ const total1w = metrics . reduce ( ( sum , segment ) => sum + ( segment . delta_1w_raw ?? 0 ) , 0 ) ;
580+ const expected = Number . isFinite ( resolveFiveHourWindowsPerWeek ( key ) )
581+ ? resolveFiveHourWindowsPerWeek ( key )
582+ : null ;
583+ const weighted = total1w > 0 ? total5h / total1w : observedMean ;
584+ const relativeDelta = expected && expected > 0 ? ( weighted - expected ) / expected : null ;
585+
586+ return {
587+ dimension,
588+ key,
589+ sample_count : metrics . length ,
590+ observed_mean_raw_ratio : roundToTwo ( observedMean ) ,
591+ observed_weighted_raw_ratio : roundToTwo ( weighted ) ,
592+ variance : roundToTwo ( variance ) ,
593+ expected_raw_ratio : expected === null ? null : roundToTwo ( expected ) ,
594+ relative_delta : relativeDelta === null ? null : roundToTwo ( relativeDelta ) ,
595+ warning :
596+ relativeDelta !== null &&
597+ metrics . length >= WATCH_HISTORY_RATIO_WARNING_MIN_SAMPLES &&
598+ Math . abs ( relativeDelta ) >= WATCH_HISTORY_RATIO_WARNING_RELATIVE_DELTA ,
599+ } ;
600+ }
601+
602+ export function computeWatchObservedRatioDiagnostics (
603+ history : WatchHistoryRecord [ ] ,
604+ now = new Date ( ) ,
605+ ) : WatchHistoryObservedRatioDiagnostic [ ] {
606+ const recentHistory = history
607+ . filter ( ( record ) => isInsideObservedRatioWindow ( record . recorded_at , now ) )
608+ . sort ( ( left , right ) => Date . parse ( left . recorded_at ) - Date . parse ( right . recorded_at ) ) ;
609+
610+ const metrics = collectContinuousSegments ( recentHistory )
611+ . map ( toSegmentMetrics )
612+ . filter (
613+ ( segment ) =>
614+ typeof segment . delta_5h_raw === "number" &&
615+ typeof segment . delta_1w_raw === "number" &&
616+ segment . delta_1w_raw > 0 ,
617+ ) ;
618+
619+ const groups = new Map < string , WatchHistorySegmentMetrics [ ] > ( ) ;
620+ const pushGroup = ( dimension : "bucket" | "plan" , key : string , segment : WatchHistorySegmentMetrics ) => {
621+ const groupKey = `${ dimension } :${ key } ` ;
622+ const existing = groups . get ( groupKey ) ;
623+ if ( existing ) {
624+ existing . push ( segment ) ;
625+ return ;
626+ }
627+ groups . set ( groupKey , [ segment ] ) ;
628+ } ;
629+
630+ for ( const segment of metrics ) {
631+ const bucket = resolveObservedRatioBucket ( segment . end . account_name ) ;
632+ if ( bucket ) {
633+ pushGroup ( "bucket" , bucket , segment ) ;
634+ }
635+ if ( segment . end . plan_type ) {
636+ pushGroup ( "plan" , segment . end . plan_type , segment ) ;
637+ }
638+ }
639+
640+ return [ ...groups . entries ( ) ]
641+ . map ( ( [ groupKey , groupMetrics ] ) => {
642+ const [ dimension , key ] = groupKey . split ( ":" , 2 ) as [ "bucket" | "plan" , string ] ;
643+ return buildObservedRatioDiagnostic ( dimension , key , groupMetrics ) ;
644+ } )
645+ . sort ( ( left , right ) =>
646+ left . dimension === right . dimension
647+ ? left . key . localeCompare ( right . key )
648+ : left . dimension . localeCompare ( right . dimension ) ,
649+ ) ;
650+ }
651+
453652function computeRemainingPercent ( window : WatchHistoryWindowSnapshot | null ) : number | null {
454653 if ( ! window ) {
455654 return null ;
@@ -662,61 +861,22 @@ function computeRateFromHistory(records: WatchHistoryRecord[]): number | null {
662861 return null ;
663862 }
664863
665- let totalDelta = 0 ;
666- let totalHours = 0 ;
667- let sawValidSegment = false ;
668- let segmentStart = records [ 0 ] ?? null ;
669- let segmentEnd = segmentStart ;
670-
671- const appendSegmentRate = ( start : WatchHistoryRecord | null , end : WatchHistoryRecord | null ) => {
672- if ( ! start || ! end || start === end ) {
673- return ;
674- }
675-
676- const elapsedHours = ( Date . parse ( end . recorded_at ) - Date . parse ( start . recorded_at ) ) / 3_600_000 ;
677- if ( elapsedHours <= 0 ) {
678- return ;
679- }
680-
681- const delta = computeSegmentDelta ( start , end ) ;
682- if ( delta === null ) {
683- return ;
684- }
685-
686- sawValidSegment = true ;
687- totalDelta += delta ;
688- totalHours += elapsedHours ;
689- } ;
690-
691- for ( let index = 1 ; index < records . length ; index += 1 ) {
692- const right = records [ index ] ;
693- const left = segmentEnd ;
694- if ( ! left || ! right ) {
695- continue ;
696- }
697-
698- const ageMs = Date . parse ( right . recorded_at ) - Date . parse ( left . recorded_at ) ;
699- if (
700- ageMs > 0 &&
701- ageMs <= WATCH_HISTORY_WINDOW_MS &&
702- isSameRateSegment ( left , right )
703- ) {
704- segmentEnd = right ;
705- continue ;
706- }
707-
708- appendSegmentRate ( segmentStart , segmentEnd ) ;
709- segmentStart = right ;
710- segmentEnd = right ;
711- }
712-
713- appendSegmentRate ( segmentStart , segmentEnd ) ;
864+ const segments = collectContinuousSegments ( records ) . map ( toSegmentMetrics ) ;
865+ const validSegments = segments . filter (
866+ ( segment ) => typeof segment . segment_delta_1w_units === "number" && segment . elapsedHours > 0 ,
867+ ) ;
714868
715- if ( ! sawValidSegment || totalHours < = 0 ) {
869+ if ( validSegments . length == = 0 ) {
716870 return null ;
717871 }
718872
719- return roundToTwo ( totalDelta / totalHours ) ;
873+ const totalDelta = validSegments . reduce (
874+ ( sum , segment ) => sum + ( segment . segment_delta_1w_units ?? 0 ) ,
875+ 0 ,
876+ ) ;
877+ const totalHours = validSegments . reduce ( ( sum , segment ) => sum + segment . elapsedHours , 0 ) ;
878+
879+ return totalHours <= 0 ? null : roundToTwo ( totalDelta / totalHours ) ;
720880}
721881
722882export function computeWatchHistoryEta (
0 commit comments