From b67239a5bef3dde78310721e6ff408a8e1faf902 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 26 Mar 2026 22:59:20 -0700 Subject: [PATCH] fix(lib/progress): estimation hotpatches these are based in reality. i think the algorithm is fine, but initially it is very hard to tell. so two small tweaks were made: - when a parent node has a shorter estimate than a child, the parent's is hidden. This catches the file scanner being silly. - progress.Ema silences output for 10 seconds AND 3 samples. after using it, i'm still not convinced it is great but its better --- lib/progress.test.ts | 78 +++++++++++++++++++++++++++++++------------- lib/progress.ts | 24 ++++++++++++-- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/lib/progress.test.ts b/lib/progress.test.ts index 6c4f3cf5edaa479aca9a77e512eb64ef4a899fae..1084374753afc29a29318120ecb5fac303606c24 100644 --- a/lib/progress.test.ts +++ b/lib/progress.test.ts @@ -105,30 +105,62 @@ test("percentValueFormatter", () => { assert.equal(progress.percentValueFormatter(0.5, null), "50%"); }); -// // ## `progress.Ema` -// // -// // the exponential moving average algorithm can be used on its own -// // -// test("Ema", () => { -// // first sample always returns null, if the progress moves linearly then the -// // estimated time will be the same. -// let ema = new progress.Ema(); -// assert.equal(ema.sample(50_000, 0), null); -// assert.equal(ema.sample(50_010, 0.1), 50_100); -// assert.equal(ema.sample(50_020, 0.2), 50_100); -// assert.equal(ema.sample(50_080, 0.8), 50_100); +test("formatAnsi hides a parent estimate shorter than a recursive child", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + try { + const child: progress.ReadOnlyNode = { + key: 2, + text: "child", + value: 1, + total: 4, + estimatedTime: 1_040_000, + passive: false, + hidden: false, + showTotal: true, + sortChildren: null, + valueFormatter: progress.defaultValueFormatter, + logs: [], + children: [], + parent: null as unknown as progress.ReadOnlyNode, + }; + const parent: progress.ReadOnlyNode = { + key: 1, + text: "parent", + value: 1, + total: 4, + estimatedTime: 1_020_000, + passive: false, + hidden: false, + showTotal: true, + sortChildren: null, + valueFormatter: progress.defaultValueFormatter, + logs: [], + children: [child], + parent: null, + }; + (child as { parent: progress.ReadOnlyNode }).parent = parent; -// // this algorithm estimates somewhat well -// ema = new progress.Ema(); -// assert.equal(ema.sample(50_000, 0), null); -// assert.equal(ema.sample(50_010, 0.1), 50_100); -// assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664); -// assert.equal(ema.sample(50_050, 0.4), 50_109.7); -// assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664); -// assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284); -// assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857); -// assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305); -// }); + const rendered = progress.formatAnsi(0, [parent]); + assert.match(rendered, /parent/); + assert.match(rendered, /child/); + assert.doesNotMatch(rendered, /parent.*20s/); + assert.match(rendered, /40s.*child/); + } finally { + now.mockRestore(); + } +}); + +test("Ema tracks warmup samples without resetting the first sample", () => { + const ema = new progress.Ema(); + assert.equal(ema.sample(1_000, 0, 100), null); + assert.equal(ema.sample(9_000, 0, 100), null); + assert.equal(ema.start, 1_000); + assert.equal(ema.sample(10_000, 50, 100), null); + assert.equal(ema.estimate, 0.25 * (50 / 9_000)); + const completion = ema.sample(11_000, 60, 100); + assert.ok(completion != null); + assert.ok(Math.abs(completion - 26_737.70491803279) < 1e-9); +}); // ## internals test("event encoding round trip cases", async () => { diff --git a/lib/progress.ts b/lib/progress.ts index 5b91f0396f245baabfefada795b1fed60eb900bb..991a74535f53e33ebe0597d347768a38ef064d26 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -656,7 +656,8 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { const { text, total, showTotal, value, estimatedTime } = state; const dateNow = Date.now(); - const estimate = estimatedTime && (estimatedTime > dateNow + 1000) + const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime); + const estimate = showEstimate && (estimatedTime > dateNow + 1000) ? ", " + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) : ""; @@ -683,6 +684,20 @@ function hasChildren(states: ReadOnlyNode): boolean { ); } +function findLongerEstimate( + state: ReadOnlyNode, + estimatedTime: number, +): boolean { + for (const child of state.children) { + if (child.hidden || (child.passive && !hasChildren(child))) continue; + if (child.estimatedTime != null && child.estimatedTime > estimatedTime) { + return true; + } + if (findLongerEstimate(child, estimatedTime)) return true; + } + return false; +} + function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { let maxHeight = 50; // TODO: flexible layout let truncated = 0; @@ -1704,18 +1719,22 @@ export interface EstimationAlgorithm { * https://en.wikipedia.org/wiki/Exponential_smoothing */ export class Ema implements EstimationAlgorithm { + /** milliseconds */ start: number | null = null; estimate: number | null = null; /** smoothing factor (0-1) */ alpha: number; + samples = 0; constructor(alpha: number = 0.25) { this.alpha = alpha; } sample(time: number, current: number, total: number): number | null { + this.samples += 1; + ASSERT(current >= 0 && current <= total); - if (this.start == null || current === 0) { + if (this.start == null) { this.start = time; this.estimate = null; return null; @@ -1728,6 +1747,7 @@ export class Ema implements EstimationAlgorithm { this.estimate = this.estimate != null ? (1 - this.alpha) * this.estimate + this.alpha * rate : rate; + if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null; const remaining = total - current; return time + remaining / this.estimate; } -- 2.54.0