| author | |
| committer | |
| log | b67239a5bef3dde78310721e6ff408a8e1faf902 |
| tree | b881f523d7adf09cfd54814b82f0141bc20b1111 |
| parent | 40d9d2642860ba857e6b46165315853ca6809923 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs |
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 better2 files changed, 78 insertions(+), 26 deletions(-)
lib/progress.test.ts+56-24| ... | ... | @@ -105,30 +105,62 @@ test("percentValueFormatter", () => { |
| 105 | 105 | assert.equal(progress.percentValueFormatter(0.5, null), "50%"); |
| 106 | 106 | }); |
| 107 | 107 | |
| 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 | // }); | |
| 108 | test("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 | ||
| 153 | test("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 | }); | |
| 132 | 164 | |
| 133 | 165 | // ## internals |
| 134 | 166 | test("event encoding round trip cases", async () => { |
lib/progress.ts+22-2| ... | ... | @@ -656,7 +656,8 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", |
| 656 | 656 | function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { |
| 657 | 657 | const { text, total, showTotal, value, estimatedTime } = state; |
| 658 | 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 | 662 | string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) |
| 662 | 663 | : ""; |
| ... | ... | @@ -683,6 +684,20 @@ function hasChildren(states: ReadOnlyNode): boolean { |
| 683 | 684 | ); |
| 684 | 685 | } |
| 685 | 686 | |
| 687 | function 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 | ||
| 686 | 701 | function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 687 | 702 | let maxHeight = 50; // TODO: flexible layout |
| 688 | 703 | let truncated = 0; |
| ... | ... | @@ -1704,18 +1719,22 @@ export interface EstimationAlgorithm { |
| 1704 | 1719 | * https://en.wikipedia.org/wiki/Exponential_smoothing |
| 1705 | 1720 | */ |
| 1706 | 1721 | export class Ema implements EstimationAlgorithm { |
| 1722 | /** milliseconds */ | |
| 1707 | 1723 | start: number | null = null; |
| 1708 | 1724 | estimate: number | null = null; |
| 1709 | 1725 | /** smoothing factor (0-1) */ |
| 1710 | 1726 | alpha: number; |
| 1727 | samples = 0; | |
| 1711 | 1728 | |
| 1712 | 1729 | constructor(alpha: number = 0.25) { |
| 1713 | 1730 | this.alpha = alpha; |
| 1714 | 1731 | } |
| 1715 | 1732 | |
| 1716 | 1733 | sample(time: number, current: number, total: number): number | null { |
| 1734 | this.samples += 1; | |
| 1735 | ||
| 1717 | 1736 | ASSERT(current >= 0 && current <= total); |
| 1718 | if (this.start == null || current === 0) { | |
| 1737 | if (this.start == null) { | |
| 1719 | 1738 | this.start = time; |
| 1720 | 1739 | this.estimate = null; |
| 1721 | 1740 | return null; |
| ... | ... | @@ -1728,6 +1747,7 @@ export class Ema implements EstimationAlgorithm { |
| 1728 | 1747 | this.estimate = this.estimate != null |
| 1729 | 1748 | ? (1 - this.alpha) * this.estimate + this.alpha * rate |
| 1730 | 1749 | : rate; |
| 1750 | if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null; | |
| 1731 | 1751 | const remaining = total - current; |
| 1732 | 1752 | return time + remaining / this.estimate; |
| 1733 | 1753 | } |