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", () => {...@@ -105,30 +105,62 @@ test("percentValueFormatter", () => {
105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
106});106});
107107
108// // ## `progress.Ema`108test("formatAnsi hides a parent estimate shorter than a recursive child", () => {
109// //109 const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
110// // the exponential moving average algorithm can be used on its own110 try {
111// //111 const child: progress.ReadOnlyNode = {
112// test("Ema", () => {112 key: 2,
113// // first sample always returns null, if the progress moves linearly then the113 text: "child",
114// // estimated time will be the same.114 value: 1,
115// let ema = new progress.Ema();115 total: 4,
116// assert.equal(ema.sample(50_000, 0), null);116 estimatedTime: 1_040_000,
117// assert.equal(ema.sample(50_010, 0.1), 50_100);117 passive: false,
118// assert.equal(ema.sample(50_020, 0.2), 50_100);118 hidden: false,
119// assert.equal(ema.sample(50_080, 0.8), 50_100);119 showTotal: true,
120120 sortChildren: null,
121// // this algorithm estimates somewhat well121 valueFormatter: progress.defaultValueFormatter,
122// ema = new progress.Ema();122 logs: [],
123// assert.equal(ema.sample(50_000, 0), null);123 children: [],
124// assert.equal(ema.sample(50_010, 0.1), 50_100);124 parent: null as unknown as progress.ReadOnlyNode,
125// assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);125 };
126// assert.equal(ema.sample(50_050, 0.4), 50_109.7);126 const parent: progress.ReadOnlyNode = {
127// assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);127 key: 1,
128// assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);128 text: "parent",
129// assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);129 value: 1,
130// assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);130 total: 4,
131// });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
133// ## internals165// ## internals
134test("event encoding round trip cases", async () => {166test("event encoding round trip cases", async () => {
lib/progress.ts+22-2
...@@ -656,7 +656,8 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",...@@ -656,7 +656,8 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
656function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {656function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
657 const { text, total, showTotal, value, estimatedTime } = state;657 const { text, total, showTotal, value, estimatedTime } = state;
658 const dateNow = Date.now();658 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)
660 ? ", " +661 ? ", " +
661 string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000))662 string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000))
662 : "";663 : "";
...@@ -683,6 +684,20 @@ function hasChildren(states: ReadOnlyNode): boolean {...@@ -683,6 +684,20 @@ function hasChildren(states: ReadOnlyNode): boolean {
683 );684 );
684}685}
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
686function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {701function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
687 let maxHeight = 50; // TODO: flexible layout702 let maxHeight = 50; // TODO: flexible layout
688 let truncated = 0;703 let truncated = 0;
...@@ -1704,18 +1719,22 @@ export interface EstimationAlgorithm {...@@ -1704,18 +1719,22 @@ export interface EstimationAlgorithm {
1704 * https://en.wikipedia.org/wiki/Exponential_smoothing1719 * https://en.wikipedia.org/wiki/Exponential_smoothing
1705 */1720 */
1706export class Ema implements EstimationAlgorithm {1721export class Ema implements EstimationAlgorithm {
1722 /** milliseconds */
1707 start: number | null = null;1723 start: number | null = null;
1708 estimate: number | null = null;1724 estimate: number | null = null;
1709 /** smoothing factor (0-1) */1725 /** smoothing factor (0-1) */
1710 alpha: number;1726 alpha: number;
1727 samples = 0;
17111728
1712 constructor(alpha: number = 0.25) {1729 constructor(alpha: number = 0.25) {
1713 this.alpha = alpha;1730 this.alpha = alpha;
1714 }1731 }
17151732
1716 sample(time: number, current: number, total: number): number | null {1733 sample(time: number, current: number, total: number): number | null {
1734 this.samples += 1;
1735
1717 ASSERT(current >= 0 && current <= total);1736 ASSERT(current >= 0 && current <= total);
1718 if (this.start == null || current === 0) {1737 if (this.start == null) {
1719 this.start = time;1738 this.start = time;
1720 this.estimate = null;1739 this.estimate = null;
1721 return null;1740 return null;
...@@ -1728,6 +1747,7 @@ export class Ema implements EstimationAlgorithm {...@@ -1728,6 +1747,7 @@ export class Ema implements EstimationAlgorithm {
1728 this.estimate = this.estimate != null1747 this.estimate = this.estimate != null
1729 ? (1 - this.alpha) * this.estimate + this.alpha * rate1748 ? (1 - this.alpha) * this.estimate + this.alpha * rate
1730 : rate;1749 : rate;
1750 if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null;
1731 const remaining = total - current;1751 const remaining = total - current;
1732 return time + remaining / this.estimate;1752 return time + remaining / this.estimate;
1733 }1753 }