use std::collections::VecDeque;
use std::path::Path;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
Decisions,
Walk,
#[default]
Read,
Lane,
Embed,
Images,
Drain,
Save,
Finalize,
Undo,
}
impl Phase {
pub fn as_str(self) -> &'static str {
match self {
Self::Decisions => "decisions",
Self::Walk => "walk",
Self::Read => "read",
Self::Lane => "lane",
Self::Embed => "embed",
Self::Images => "images",
Self::Drain => "drain",
Self::Save => "save",
Self::Finalize => "finalize",
Self::Undo => "undo",
}
}
}
pub const IMAGE_UNITS: f64 = 8.0;
pub const FILE_UNITS: f64 = 0.5;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Work {
pub files: (u64, u64),
pub chunks: (u64, u64),
pub images: (u64, u64),
pub image_weight: f64,
pub parallel: bool,
}
pub fn image_units() -> f64 {
let rates = crate::accel::rates();
match (crate::accel::expected_rate(), rates.get("clip")) {
(Some(chunks), Some(clip)) if clip.per_s > 0.0 => (chunks / clip.per_s).clamp(1.0, 200.0),
_ => IMAGE_UNITS,
}
}
impl Work {
pub fn units(&self) -> (f64, f64) {
let (f, ft) = self.files;
let (c, ct) = self.chunks;
let (i, it) = self.images;
let w = if self.image_weight > 0.0 {
self.image_weight
} else {
IMAGE_UNITS
};
let files = (f.min(ft) as f64 * FILE_UNITS, ft as f64 * FILE_UNITS);
if self.parallel {
let all = (ct as f64).max(it as f64 * w) + files.1;
let left =
((ct - c.min(ct)) as f64).max((it - i.min(it)) as f64 * w) + files.1 - files.0;
return (all - left, all);
}
let done = c.min(ct) as f64 + i.min(it) as f64 * w + files.0;
let all = ct as f64 + it as f64 * w + files.1;
(done, all)
}
pub fn eta_units(&self) -> (f64, f64) {
Work {
files: (0, 0),
..*self
}
.units()
}
pub fn share(&self) -> f64 {
let (done, all) = self.units();
if all <= 0.0 {
0.0
} else {
(done / all).clamp(0.0, 1.0)
}
}
}
fn bytes_per_chunk(path: &Path) -> Option<u64> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
Some(match ext.as_str() {
"json" | "jsonl" | "ndjson" | "log" | "csv" | "tsv" => 700,
"md" | "markdown" | "mdx" | "txt" | "rst" | "adoc" => 900,
"html" | "htm" | "xml" | "svg" => 1_500,
"pdf" => 4_000,
"docx" | "pptx" | "xlsx" | "odt" | "odp" | "ods" | "epub" => 6_000,
"lock" => 1_200,
_ => 1_100,
})
}
pub fn estimate(path: &Path, bytes: u64, planned: Option<u64>) -> (u64, bool) {
if crate::image::is_image(path) {
return (0, true);
}
if let Some(n) = planned {
return (n, false);
}
if bytes == 0 {
return (0, false);
}
let per = bytes_per_chunk(path).unwrap_or(1_100);
(bytes.div_ceil(per).max(1), false)
}
const STEP: f64 = 1.25;
const RISE_FLOOR: f64 = 10.0;
const TAU: f64 = 90.0;
const SETTLE: f64 = 60.0;
#[derive(Debug, Clone, Default)]
pub struct Eta {
last: Option<(f64, f64)>,
rate: Option<f64>,
measured: f64,
recent: VecDeque<(f64, f64)>,
shown: Option<(f64, f64)>,
prior: Option<f64>,
work: Option<f64>,
}
impl Eta {
pub fn with_prior(prior: Option<f64>) -> Self {
Self {
prior: prior.filter(|r| *r > 0.0),
..Self::default()
}
}
pub fn set_prior(&mut self, prior: Option<f64>) {
self.prior = prior.filter(|r| *r > 0.0);
}
pub fn observe(&mut self, at: f64, done: f64) {
if let Some((t, d)) = self.last {
let dt = at - t;
if dt < 1.0 {
return;
}
let moved = (done - d).max(0.0);
let now = moved / dt;
if moved > 0.0 {
self.measured += dt;
}
let w = 1.0 - (-dt / TAU).exp();
self.rate = Some(match self.rate {
Some(r) if moved > 0.0 || self.measured > 0.0 => r + w * (now - r),
Some(r) => r,
None if moved > 0.0 => self.prior.map_or(now, |p| p + w * (now - p)),
None => return self.last = Some((at, done)),
});
self.recent.push_back((at, now));
while self.recent.front().is_some_and(|(t0, _)| at - t0 > 60.0) {
self.recent.pop_front();
}
}
self.last = Some((at, done));
}
pub fn observe_unless(&mut self, held: bool, at: f64, done: f64) {
if held {
self.last = Some((at, done));
} else {
self.observe(at, done);
}
}
fn rate(&self) -> Option<f64> {
let measured = self.rate.filter(|r| *r > 0.0);
match (measured, self.prior) {
(Some(m), Some(p)) if self.measured < SETTLE => {
let k = self.measured / SETTLE;
Some(p + k * (m - p))
}
(Some(m), _) => Some(m),
(None, p) => p,
}
}
pub fn left(&mut self, at: f64, remaining: f64, hold: bool) -> Option<(u64, (u64, u64))> {
let rate = self.rate()?;
let raw = remaining.max(0.0) / rate;
let rescoped = self
.work
.is_some_and(|w| w > 0.0 && ((remaining / w).ln().abs() > STEP.ln()));
if rescoped {
self.shown = None;
}
self.work = Some(remaining.max(1.0));
let value = match self.shown {
Some((t, shown)) => {
let dt = (at - t).max(0.0);
if hold {
shown
} else {
let counted = (shown - dt).max(0.0);
let f = STEP.powf(dt / 10.0);
raw.clamp(counted / f, counted.max(RISE_FLOOR) * f)
}
}
None => raw,
};
self.shown = Some((at, value));
let rates: Vec<f64> = self
.recent
.iter()
.map(|(_, r)| *r)
.filter(|r| *r > 0.0)
.collect();
let (lo, hi) = if self.measured >= SETTLE && rates.len() >= 3 {
let fast = rates.iter().cloned().fold(f64::MIN, f64::max);
let slow = rates.iter().cloned().fold(f64::MAX, f64::min);
((remaining / fast).min(value), (remaining / slow).max(value))
} else {
(value * 0.7, value * 1.5)
};
let ms = |s: f64| (s * 1000.0).round() as u64;
Some((ms(value), (ms(lo), ms(hi))))
}
}
pub fn grouped(n: u64) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn counts_are_grouped_by_thousands() {
assert_eq!(super::grouped(0), "0");
assert_eq!(super::grouped(999), "999");
assert_eq!(super::grouped(5_000), "5,000");
assert_eq!(super::grouped(1_234_567), "1,234,567");
}
use super::*;
use std::path::PathBuf;
#[test]
fn units_count_files_chunks_and_images() {
let w = Work {
files: (10, 100),
chunks: (500, 1_000),
images: (2, 4),
image_weight: IMAGE_UNITS,
parallel: false,
};
let (done, all) = w.units();
assert_eq!(done, 500.0 + 16.0 + 5.0);
assert_eq!(all, 1_000.0 + 32.0 + 50.0);
assert!(w.share() < 0.5);
}
#[test]
fn a_held_stretch_does_not_drag_the_rate() {
let mut held = Eta::with_prior(Some(100.0));
let mut sampled = Eta::with_prior(Some(100.0));
for t in 0..135 {
held.observe_unless(true, t as f64, t as f64 * 0.1);
sampled.observe(t as f64, t as f64 * 0.1);
}
for t in 135..195 {
let done = 13.5 + (t - 135) as f64 * 100.0;
held.observe_unless(false, t as f64, done);
sampled.observe(t as f64, done);
}
let (h, s) = (held.rate().unwrap(), sampled.rate().unwrap());
assert!(h > 90.0, "held {h}");
assert!(h > s, "held {h} sampled {s}");
}
#[test]
fn time_left_counts_the_embedding_only() {
let w = Work {
files: (900, 1_500),
chunks: (2_700, 8_700),
images: (0, 0),
image_weight: IMAGE_UNITS,
parallel: true,
};
assert_eq!(w.eta_units(), (2_700.0, 8_700.0));
assert!(w.units().1 > w.eta_units().1);
}
#[test]
fn parallel_work_is_the_longer_path() {
let mut w = Work {
files: (0, 0),
chunks: (500, 1_000),
images: (10, 100),
image_weight: 8.0,
parallel: true,
};
assert_eq!(w.units(), (280.0, 1_000.0));
w.images = (100, 100);
assert_eq!(w.units(), (500.0, 1_000.0));
}
#[test]
fn a_planned_count_wins_and_an_image_is_an_image() {
assert_eq!(
estimate(&PathBuf::from("a.rs"), 11_000, Some(4)),
(4, false)
);
assert_eq!(estimate(&PathBuf::from("a.rs"), 11_000, None), (10, false));
assert_eq!(
estimate(&PathBuf::from("a.jsonl"), 7_000, None),
(10, false)
);
assert_eq!(
estimate(&PathBuf::from("shot.png"), 3_000_000, None),
(0, true)
);
assert_eq!(estimate(&PathBuf::from("empty.md"), 0, None), (0, false));
}
#[test]
fn the_shown_time_moves_at_most_a_quarter_in_ten_seconds() {
let mut eta = Eta::with_prior(Some(100.0));
let mut shown = Vec::new();
let mut expected = 60_000.0;
for s in 0..600 {
let t = s as f64;
let done = t * 100.0;
if s == 120 {
expected += 30_000.0;
}
eta.observe(t, done);
if let Some((ms, (lo, hi))) = eta.left(t, expected - done, false) {
assert!(lo <= ms && ms <= hi, "{lo} {ms} {hi}");
shown.push((t, ms as f64 / 1000.0));
}
}
for pair in shown
.windows(11)
.filter(|p| !(p[0].0 < 120.0 && p[10].0 >= 120.0))
{
let (t0, a) = pair[0];
let (t1, b) = pair[10];
let counted = (a - (t1 - t0)).max(1.0);
assert!(
b <= a * STEP * 1.0001 && b >= counted / STEP / 1.0001,
"{a} -> {b}"
);
}
let (_, last) = *shown.last().unwrap();
let truth = (expected - 599.0 * 100.0) / 100.0;
assert!((last - truth).abs() / truth < 0.05, "{last} vs {truth}");
}
#[test]
fn the_figure_recovers_when_the_rate_falls() {
let mut eta = Eta::with_prior(Some(100.0));
let total = 2_000.0;
let mut done = 0.0;
for s in 0..120 {
let t = s as f64;
done = if s <= 35 {
0.0
} else {
(done + 13.0_f64).min(total)
};
eta.observe(t, done);
let remaining = total - done;
if let Some((ms, _)) = eta.left(t, remaining, false) {
let at_rate = remaining / eta.rate().unwrap();
assert!(
at_rate <= 10.0 || ms >= 5_000,
"at {t} s: {ms} ms shown, {at_rate:.1} s left at the measured rate"
);
}
}
}
#[test]
fn a_hold_keeps_the_figure_and_no_prior_and_no_measure_says_nothing() {
let mut eta = Eta::default();
assert_eq!(eta.left(0.0, 1_000.0, false), None);
let mut eta = Eta::with_prior(Some(10.0));
let (first, _) = eta.left(0.0, 1_000.0, false).unwrap();
let (held, _) = eta.left(30.0, 1_000.0, true).unwrap();
assert_eq!(first, held);
}
}