Skip to content

Commit 9c36b7c

Browse files
author
liyanbowne
committed
feat(debug): warn on observed quota ratio mismatch
1 parent 0032e00 commit 9c36b7c

File tree

5 files changed

+436
-51
lines changed

5 files changed

+436
-51
lines changed

src/commands/inspection.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { writeJson } from "../cli/output.js";
2323
import {
2424
computeWatchHistoryEta,
25+
computeWatchObservedRatioDiagnostics,
2526
createWatchHistoryStore,
2627
type WatchHistoryEtaContext,
2728
} from "../watch/history.js";
@@ -639,6 +640,7 @@ export async function handleListCommand(options: {
639640
store: AccountStore;
640641
stdout: NodeJS.WriteStream;
641642
debugLog: DebugLogger;
643+
debug: boolean;
642644
json: boolean;
643645
targetName?: string;
644646
verbose: boolean;
@@ -658,6 +660,23 @@ export async function handleListCommand(options: {
658660
options.debugLog(
659661
`list: target=${options.targetName ?? "all"} successes=${result.successes.length} failures=${result.failures.length} current_matches=${current.matched_accounts.length} watch_history_samples=${watchHistory.length}`,
660662
);
663+
if (options.debug) {
664+
const ratioDiagnostics = computeWatchObservedRatioDiagnostics(watchHistory, now);
665+
if (ratioDiagnostics.length === 0) {
666+
options.debugLog("list: observed_ratio window=24h insufficient_samples");
667+
} else {
668+
for (const diagnostic of ratioDiagnostics) {
669+
options.debugLog(
670+
`list: observed_ratio window=24h dimension=${diagnostic.dimension} key=${diagnostic.key} samples=${diagnostic.sample_count} expected=${diagnostic.expected_raw_ratio ?? "n/a"} observed_weighted=${diagnostic.observed_weighted_raw_ratio} mean=${diagnostic.observed_mean_raw_ratio} variance=${diagnostic.variance}`,
671+
);
672+
if (diagnostic.warning) {
673+
options.debugLog(
674+
`warning: list observed_ratio_mismatch dimension=${diagnostic.dimension} key=${diagnostic.key} expected=${diagnostic.expected_raw_ratio ?? "n/a"} observed_weighted=${diagnostic.observed_weighted_raw_ratio} relative_delta=${diagnostic.relative_delta ?? "n/a"} samples=${diagnostic.sample_count}`,
675+
);
676+
}
677+
}
678+
}
679+
}
661680
if (options.json) {
662681
writeJson(options.stdout, {
663682
...toCliQuotaRefreshResult(result),

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export async function runCli(
198198
store,
199199
stdout: streams.stdout,
200200
debugLog,
201+
debug,
201202
json,
202203
targetName: parsed.positionals[0],
203204
verbose: parsed.flags.has("--verbose"),

src/watch/history.ts

Lines changed: 211 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import { dirname, join } from "node:path";
44
import {
55
convertFiveHourPercentToPlusWeeklyUnits,
66
convertOneWeekPercentToPlusWeeklyUnits,
7+
resolveFiveHourWindowsPerWeek,
78
} from "../plan-quota-profile.js";
89

910
const WATCH_HISTORY_FILE_NAME = "watch-quota-history.jsonl";
1011
const WATCH_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
1112
const WATCH_HISTORY_KEEPALIVE_MS = 60 * 1000;
1213
const WATCH_HISTORY_WINDOW_MS = 60 * 60 * 1000;
1314
const 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

1519
export interface WatchHistoryWindowSnapshot {
1620
used_percent: number;
@@ -69,6 +73,18 @@ export interface WatchHistoryStore {
6973
export type WatchQuotaHistoryRecord = WatchHistoryRecord;
7074
export 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+
7288
function 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+
195216
function 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+
453652
function 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

722882
export function computeWatchHistoryEta(

0 commit comments

Comments
 (0)