authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-26 22:59:20-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-26 23:33:11-07:00
logb67239a5bef3dde78310721e6ff408a8e1faf902
treeb881f523d7adf09cfd54814b82f0141bc20b1111
parent40d9d2642860ba857e6b46165315853ca6809923
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

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

2 files changed, 78 insertions(+), 26 deletions(-)

lib/progress.test.ts+56-24
......@@ -105,30 +105,62 @@ test("percentValueFormatter", () => {
105105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
106106});
107107
108// // ## `progress.Ema`
109// //
110// // the exponential moving average algorithm can be used on its own
111// //
112// test("Ema", () => {
113// // first sample always returns null, if the progress moves linearly then the
114// // estimated time will be the same.
115// let ema = new progress.Ema();
116// assert.equal(ema.sample(50_000, 0), null);
117// assert.equal(ema.sample(50_010, 0.1), 50_100);
118// assert.equal(ema.sample(50_020, 0.2), 50_100);
119// assert.equal(ema.sample(50_080, 0.8), 50_100);
120
121// // this algorithm estimates somewhat well
122// ema = new progress.Ema();
123// assert.equal(ema.sample(50_000, 0), null);
124// assert.equal(ema.sample(50_010, 0.1), 50_100);
125// assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);
126// assert.equal(ema.sample(50_050, 0.4), 50_109.7);
127// assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);
128// assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);
129// assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);
130// assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);
131// });
108test("formatAnsi hides a parent estimate shorter than a recursive child", () => {
109 const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
110 try {
111 const child: progress.ReadOnlyNode = {
112 key: 2,
113 text: "child",
114 value: 1,
115 total: 4,
116 estimatedTime: 1_040_000,
117 passive: false,
118 hidden: false,
119 showTotal: true,
120 sortChildren: null,
121 valueFormatter: progress.defaultValueFormatter,
122 logs: [],
123 children: [],
124 parent: null as unknown as progress.ReadOnlyNode,
125 };
126 const parent: progress.ReadOnlyNode = {
127 key: 1,
128 text: "parent",
129 value: 1,
130 total: 4,
131 estimatedTime: 1_020_000,
132 passive: false,
133 hidden: false,
134 showTotal: true,
135 sortChildren: null,
136 valueFormatter: progress.defaultValueFormatter,
137 logs: [],
138 children: [child],
139 parent: null,
140 };
141 (child as { parent: progress.ReadOnlyNode }).parent = parent;
142
143 const rendered = progress.formatAnsi(0, [parent]);
144 assert.match(rendered, /parent/);
145 assert.match(rendered, /child/);
146 assert.doesNotMatch(rendered, /parent.*20s/);
147 assert.match(rendered, /40s.*child/);
148 } finally {
149 now.mockRestore();
150 }
151});
152
153test("Ema tracks warmup samples without resetting the first sample", () => {
154 const ema = new progress.Ema();
155 assert.equal(ema.sample(1_000, 0, 100), null);
156 assert.equal(ema.sample(9_000, 0, 100), null);
157 assert.equal(ema.start, 1_000);
158 assert.equal(ema.sample(10_000, 50, 100), null);
159 assert.equal(ema.estimate, 0.25 * (50 / 9_000));
160 const completion = ema.sample(11_000, 60, 100);
161 assert.ok(completion != null);
162 assert.ok(Math.abs(completion - 26_737.70491803279) < 1e-9);
163});
132164
133165// ## internals
134166test("event encoding round trip cases", async () => {
lib/progress.ts+22-2
......@@ -656,7 +656,8 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
656656function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
657657 const { text, total, showTotal, value, estimatedTime } = state;
658658 const dateNow = Date.now();
659 const estimate = estimatedTime && (estimatedTime > dateNow + 1000)
659 const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime);
660 const estimate = showEstimate && (estimatedTime > dateNow + 1000)
660661 ? ", " +
661662 string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000))
662663 : "";
......@@ -683,6 +684,20 @@ function hasChildren(states: ReadOnlyNode): boolean {
683684 );
684685}
685686
687function findLongerEstimate(
688 state: ReadOnlyNode,
689 estimatedTime: number,
690): boolean {
691 for (const child of state.children) {
692 if (child.hidden || (child.passive && !hasChildren(child))) continue;
693 if (child.estimatedTime != null && child.estimatedTime > estimatedTime) {
694 return true;
695 }
696 if (findLongerEstimate(child, estimatedTime)) return true;
697 }
698 return false;
699}
700
686701function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
687702 let maxHeight = 50; // TODO: flexible layout
688703 let truncated = 0;
......@@ -1704,18 +1719,22 @@ export interface EstimationAlgorithm {
17041719 * https://en.wikipedia.org/wiki/Exponential_smoothing
17051720 */
17061721export class Ema implements EstimationAlgorithm {
1722 /** milliseconds */
17071723 start: number | null = null;
17081724 estimate: number | null = null;
17091725 /** smoothing factor (0-1) */
17101726 alpha: number;
1727 samples = 0;
17111728
17121729 constructor(alpha: number = 0.25) {
17131730 this.alpha = alpha;
17141731 }
17151732
17161733 sample(time: number, current: number, total: number): number | null {
1734 this.samples += 1;
1735
17171736 ASSERT(current >= 0 && current <= total);
1718 if (this.start == null || current === 0) {
1737 if (this.start == null) {
17191738 this.start = time;
17201739 this.estimate = null;
17211740 return null;
......@@ -1728,6 +1747,7 @@ export class Ema implements EstimationAlgorithm {
17281747 this.estimate = this.estimate != null
17291748 ? (1 - this.alpha) * this.estimate + this.alpha * rate
17301749 : rate;
1750 if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null;
17311751 const remaining = total - current;
17321752 return time + remaining / this.estimate;
17331753 }