Skip to main content

sioul_core/
capacity.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a day can hold, learned from your own days (docs/capacity.md).
5//!
6//! - **Heaviness** of a task or an event is its highest cost (`Demands::level`);
7//!   unrated, its word (`level_of`).
8//! - **Ratings for the plan**: said after the task when you said so (the
9//!   median of the last five of the same item, else of its kind), else
10//!   forecast (`FeltIndex`). Ratings given after are more accurate, anxiety
11//!   is over-predicted beforehand (capacity-budget.md, criteria 6–7; G6).
12//! - **Load** of an item: rating × hours, at least half an hour each
13//!   (session-RPE, Foster 2001; criteria 10–11), per cost and in total.
14//! - **Time**: your estimate corrected by your own record, the log of time
15//!   spent over the first guess on finished tasks, recent ones weighing more,
16//!   pulled toward 1.1× until about nine tasks stand behind it, per area
17//!   pulled toward yours (`Ratios`; time-estimation.md, TE7–15); the day's
18//!   margin held as free time at the 85th percentile of the day's total, by
19//!   a seeded simulation with a shared day effect (`slack`; TE16–20).
20//! - **Budgets**, per cost and in total, learned from days whose outcome you
21//!   gave (`learn`; criteria 13–25), replayed from the record each time so
22//!   that every device, holding the same record, plans the same.
23//! - **The gain minimum**: good hours on good days, their 25th percentile,
24//!   never learned downward from a bad stretch (G8–G10).
25//!
26//! Nothing here is ever shown as a number: the pages get words and reasons.
27
28use crate::agenda::Occurrence;
29use crate::demands::{Demands, Level};
30use crate::i18n::Translator;
31use crate::tasks::{Status, Task};
32use crate::timelog::{Kind, Session};
33use fluent_bundle::FluentArgs;
34use jiff::civil::Date;
35use jiff::tz::TimeZone;
36use serde::{Deserialize, Serialize};
37use std::collections::{BTreeMap, BTreeSet};
38
39/// The four costs, then their total, in this order everywhere.
40pub const SCALES: [&str; 5] = ["cognitive", "emotional", "anxiety", "body", "total"];
41/// The total's place in a `Load`.
42pub const TOTAL: usize = 4;
43/// Rating × hours, per cost and in total.
44pub type Load = [f32; 5];
45
46/// Every item counts for at least this long, so a dreaded ten-minute call is
47/// not free (criterion 11). A guess to tune.
48pub const LEAST_MINUTES: u32 = 30;
49/// Each day is planned to this share of each budget (criterion 27). A guess (CB-Q18).
50pub const FILL: f32 = 0.85;
51/// Days before and after a heavy event hold this share (criterion 34). A guess.
52pub const AROUND_HEAVY: f32 = 0.75;
53/// An event this heavy (its highest cost) lightens the days around it.
54pub const HEAVY_EVENT: u8 = 7;
55
56/// A task's heaviness: from its costs when any is rated, else its word.
57pub fn level_of(task: &Task) -> Level {
58    task.demands.level().unwrap_or_else(|| Level::of_energy(&task.energy))
59}
60
61fn rounded(x: f32) -> u8 {
62    x.round().clamp(0.0, 10.0) as u8
63}
64
65/// The ratings the plan uses for an item: each cost and the gain, possibly
66/// medians of several ratings, and its word for what is unrated.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct Rates {
69    pub costs: [Option<f32>; 4],
70    pub gain: Option<f32>,
71    /// Its word, for an item without any cost rated.
72    pub word: Level,
73}
74
75impl Default for Rates {
76    fn default() -> Rates {
77        Rates { costs: [None; 4], gain: None, word: Level::Usual }
78    }
79}
80
81impl Rates {
82    pub fn of(demands: &Demands, word: Level) -> Rates {
83        Rates { costs: demands.costs().map(|c| c.map(f32::from)), gain: demands.gain.map(f32::from), word }
84    }
85
86    /// The ratings an item counts at, as written in it: a task's, an event's.
87    pub fn of_task(task: &Task) -> Rates {
88        Rates::of(&task.demands, Level::of_energy(&task.energy))
89    }
90
91    pub fn of_event(event: &Occurrence) -> Rates {
92        Rates::of(&event.demands, Level::Usual)
93    }
94
95    pub fn highest(&self) -> Option<f32> {
96        self.costs.iter().flatten().copied().fold(None, |m: Option<f32>, v| Some(m.map_or(v, |m| m.max(v))))
97    }
98
99    /// Heaviness: the highest cost when any is rated, else the word.
100    pub fn level(&self) -> Level {
101        match self.highest() {
102            Some(h) => Level::of(rounded(h), self.gain.map(rounded)),
103            None => self.word,
104        }
105    }
106
107    /// The load of an hour: each cost rated, and the total at the item's
108    /// heaviness: its highest cost, or its word's rating when none is rated
109    /// (light 2, usual 4, heavy 7; docs/capacity.md, why the highest and not the sum).
110    pub fn per_hour(&self) -> Load {
111        let mut load = [0.0; 5];
112        for (s, cost) in self.costs.iter().enumerate() {
113            load[s] = cost.unwrap_or(0.0);
114        }
115        load[TOTAL] = self.highest().unwrap_or_else(|| self.word.rating());
116        load
117    }
118
119    /// The load of `minutes` of it, half an hour at least.
120    pub fn load(&self, minutes: u32) -> Load {
121        let hours = minutes.max(LEAST_MINUTES) as f32 / 60.0;
122        self.per_hour().map(|r| r * hours)
123    }
124
125    /// The cost weighing most, when it weighs (4 or more): to alternate kinds of cost.
126    pub fn dominant(&self) -> Option<usize> {
127        let (index, value) = self.costs.iter().enumerate().filter_map(|(i, c)| c.map(|c| (i, c))).fold(None, |best: Option<(usize, f32)>, (i, c)| match best {
128            Some((_, b)) if b >= c => best,
129            _ => Some((i, c)),
130        })?;
131        (value >= 4.0).then_some(index)
132    }
133
134    /// Which costs are known.
135    pub fn known(&self) -> [bool; 5] {
136        let mut known = [false; 5];
137        for (s, cost) in self.costs.iter().enumerate() {
138            known[s] = cost.is_some();
139        }
140        known[TOTAL] = true;
141        known
142    }
143}
144
145/// Adds `b` into `a`.
146pub fn add(a: &mut Load, b: &Load) {
147    for s in 0..5 {
148        a[s] += b[s];
149    }
150}
151
152/// The felt ratings of every task, to plan from (G6): by item, a task's
153/// folded title (a repeating task, or one made again each time), and by kind.
154#[derive(Debug, Clone, Default)]
155pub struct FeltIndex {
156    items: BTreeMap<String, Vec<(Option<Date>, Demands)>>,
157    kinds: BTreeMap<String, Vec<(Option<Date>, Demands)>>,
158}
159
160/// How many felt ratings the median looks back over (G6). A guess.
161pub const FELT_MEDIAN_OF: usize = 5;
162
163fn item_key(task: &Task) -> String {
164    let title: String = crate::text::fold(task.title.trim()).into_iter().collect::<String>().to_lowercase();
165    if title.is_empty() { format!("uid:{}", task.uid) } else { title }
166}
167
168impl FeltIndex {
169    pub fn of(tasks: &[Task]) -> FeltIndex {
170        let mut index = FeltIndex::default();
171        let mut seen: BTreeSet<&str> = BTreeSet::new();
172        for task in tasks.iter().filter(|t| !t.felt.is_empty()) {
173            if !seen.insert(task.uid.as_str()) {
174                continue;
175            }
176            let ratings: Vec<(Option<Date>, Demands)> = task.felt.iter().map(|f| (f.on, f.demands)).collect();
177            index.items.entry(item_key(task)).or_default().extend(ratings.iter().copied());
178            if !task.kind.is_empty() {
179                index.kinds.entry(task.kind.clone()).or_default().extend(ratings);
180            }
181        }
182        for list in index.items.values_mut().chain(index.kinds.values_mut()) {
183            list.sort_by_key(|(on, _)| *on);
184        }
185        index
186    }
187
188    /// The median of the last ratings of one value, oldest first.
189    fn median(ratings: &[(Option<Date>, Demands)], pick: impl Fn(&Demands) -> Option<u8>) -> Option<f32> {
190        let mut last: Vec<f32> = ratings.iter().rev().filter_map(|(_, d)| pick(d)).take(FELT_MEDIAN_OF).map(f32::from).collect();
191        if last.is_empty() {
192            return None;
193        }
194        last.sort_by(f32::total_cmp);
195        let n = last.len();
196        Some(if n % 2 == 1 { last[n / 2] } else { (last[n / 2 - 1] + last[n / 2]) / 2.0 })
197    }
198
199    /// The ratings the plan uses for a task, value by value: the median of
200    /// the last five felt of the same item; else its forecast; else the median
201    /// of the last five felt of its kind (call, write…) for what it leaves unsaid.
202    pub fn rates(&self, task: &Task) -> Rates {
203        let item = self.items.get(&item_key(task)).map(Vec::as_slice).unwrap_or(&[]);
204        let kind = if task.kind.is_empty() { &[][..] } else { self.kinds.get(&task.kind).map(Vec::as_slice).unwrap_or(&[]) };
205        let value = |pick: &dyn Fn(&Demands) -> Option<u8>| FeltIndex::median(item, pick).or_else(|| pick(&task.demands).map(f32::from)).or_else(|| FeltIndex::median(kind, pick));
206        let costs = [value(&|d: &Demands| d.cognitive), value(&|d: &Demands| d.emotional), value(&|d: &Demands| d.anxiety), value(&|d: &Demands| d.body)];
207        Rates { costs, gain: value(&|d: &Demands| d.gain), word: Level::of_energy(&task.energy) }
208    }
209
210    /// What was felt before, shown faintly in a task's form for what it
211    /// leaves unsaid (docs/capacity.md, "The form"): value by value, the
212    /// median of the last five felt after the same item, else after tasks of
213    /// its kind, rounded. Never its own forecast: the form is where the
214    /// forecast is said.
215    pub fn proposal(&self, task: &Task) -> Proposal {
216        let item = self.items.get(&item_key(task)).map(Vec::as_slice).unwrap_or(&[]);
217        let kind = if task.kind.is_empty() { &[][..] } else { self.kinds.get(&task.kind).map(Vec::as_slice).unwrap_or(&[]) };
218        let (mut from_item, mut from_kind) = (false, false);
219        let mut value = |pick: &dyn Fn(&Demands) -> Option<u8>| -> Option<u8> {
220            if let Some(median) = FeltIndex::median(item, pick) {
221                from_item = true;
222                return Some(rounded(median));
223            }
224            let median = FeltIndex::median(kind, pick)?;
225            from_kind = true;
226            Some(rounded(median))
227        };
228        let values = Demands {
229            cognitive: value(&|d: &Demands| d.cognitive),
230            emotional: value(&|d: &Demands| d.emotional),
231            anxiety: value(&|d: &Demands| d.anxiety),
232            body: value(&|d: &Demands| d.body),
233            gain: value(&|d: &Demands| d.gain),
234        };
235        let from = match (from_item, from_kind) {
236            (true, true) => "both",
237            (true, false) => "item",
238            (false, true) => "kind",
239            (false, false) => "",
240        };
241        Proposal { values, from }
242    }
243
244    /// Whether anything was said after any task.
245    pub fn is_empty(&self) -> bool {
246        self.items.is_empty()
247    }
248}
249
250/// What a task's form shows faintly for its unsaid costs and gain
251/// (`FeltIndex::proposal`): nothing is kept until "Looks right" or a tap.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
253pub struct Proposal {
254    pub values: Demands,
255    /// Where they come from: "item" (the same task, or tasks of the same
256    /// title), "kind" (tasks of its kind), "both"; "" when there are none.
257    pub from: &'static str,
258}
259
260/* ---------- Time: the estimate corrected by your own record ---------- */
261
262/// Before your own record: tasks take about 1.1× the first guess (TE9: 1.0–1.2×).
263pub const PRIOR_RATIO: f32 = 1.1;
264/// The spread of the log ratio before your own record (TE9).
265pub const PRIOR_SIGMA: f32 = 0.5;
266/// Your ratio stands on its own from this many tasks (TE9: 8–10). A guess.
267pub const STANDS_FROM: f32 = 9.0;
268/// A kind's ratio counts as much as yours from this many tasks (TE10: 4–11). A guess.
269pub const KIND_PULL: f32 = 5.0;
270/// Days for a task's weight to halve (TE8: 4–5).
271pub const RATIO_HALF_LIFE: f32 = 4.5;
272/// What a typed or unknown minute weighs against a timed one (TE15: little). A guess.
273pub const TYPED_WEIGHT: f32 = 0.25;
274/// Finished tasks older than this are not looked at.
275pub const RATIO_HORIZON: i64 = 90;
276/// The share of a day's spread that is the day itself: a bad day slows every task (TE18). A guess.
277pub const DAY_SHARE: f32 = 0.3;
278/// The day's total is planned to this percentile (TE17: 80–90). A guess.
279pub const DAY_PERCENTILE: f32 = 0.85;
280/// Draws of the day's simulation.
281pub const DRAWS: usize = 2000;
282/// The standard normal's quantile at `DAY_PERCENTILE` (0.85).
283pub const Z_DAY: f32 = 1.036_433;
284/// The free time a day keeps for steps running long is at most this share of
285/// its room for tasks, so that it never empties a day, thin data included. A
286/// guess to tune (docs/capacity.md, "The day's free time").
287pub const SLACK_SHARE: f32 = 1.0 / 3.0;
288
289/// A finished task, as the ratio learns from it.
290#[derive(Debug, Clone, PartialEq)]
291pub struct Finished {
292    pub uid: String,
293    /// Its kind for the ratio: its area ("work", "admin", "leisure").
294    pub kind: &'static str,
295    pub done: Date,
296    /// ln(time spent / first estimate).
297    pub log: f32,
298    /// What its minutes weigh: timed 1, typed or unknown `TYPED_WEIGHT`, mixed by minutes.
299    pub weight: f32,
300}
301
302/// Each finished task with an estimate and time spent: its log ratio, all its
303/// sessions summed (TE14); the first estimate, else the current one (a task
304/// estimated before the first was kept). Dropped tasks never count.
305pub fn finished(tasks: &[Task], sessions: &[Session], kind_of: &dyn Fn(&Task) -> &'static str, zone: &TimeZone) -> Vec<Finished> {
306    let mut spent: BTreeMap<&str, (u32, f32)> = BTreeMap::new();
307    for s in sessions.iter().filter(|s| !s.task.is_empty()) {
308        let entry = spent.entry(s.task.as_str()).or_insert((0, 0.0));
309        entry.0 = entry.0.saturating_add(s.minutes);
310        entry.1 += s.minutes as f32 * if Kind::timed(s.kind) { 1.0 } else { TYPED_WEIGHT };
311    }
312    let mut seen: BTreeSet<&str> = BTreeSet::new();
313    tasks
314        .iter()
315        .filter(|t| t.status == Status::Completed && seen.insert(t.uid.as_str()))
316        .filter_map(|t| {
317            let estimate = t.estimate_first.unwrap_or(t.estimate);
318            let &(minutes, weighed) = spent.get(t.uid.as_str())?;
319            if estimate == 0 || minutes == 0 {
320                return None;
321            }
322            let done = jiff::Timestamp::from_second(t.completed?).ok()?.to_zoned(zone.clone()).date();
323            let log = (minutes as f32 / estimate as f32).ln().clamp(-(8f32.ln()), 8f32.ln());
324            Some(Finished { uid: t.uid.clone(), kind: kind_of(t), done, log, weight: weighed / minutes as f32 })
325        })
326        .collect()
327}
328
329/// Your ratio of time spent to first guesses, and each kind's.
330#[derive(Debug, Clone, PartialEq)]
331pub struct Ratios {
332    /// The log of your ratio, pulled toward the prior until it stands.
333    pub person: f32,
334    /// The weight of tasks behind it (typed ones counting little).
335    pub count: f32,
336    /// Each kind: its log ratio pulled toward yours, and the weight behind it.
337    pub kinds: BTreeMap<&'static str, (f32, f32)>,
338    /// The spread of a task's log ratio around its kind's.
339    pub sigma: f32,
340    /// Today's own effect so far (log), from what was finished today, and how sure it is (its σ).
341    pub today: f32,
342    pub today_sigma: f32,
343}
344
345impl Default for Ratios {
346    fn default() -> Ratios {
347        let spread = Spread::of(PRIOR_SIGMA);
348        Ratios { person: PRIOR_RATIO.ln(), count: 0.0, kinds: BTreeMap::new(), sigma: PRIOR_SIGMA, today: 0.0, today_sigma: spread.day }
349    }
350}
351
352/// The weighted median of (value, weight).
353fn weighted_quantile(mut values: Vec<(f32, f32)>, q: f32) -> Option<f32> {
354    values.retain(|(_, w)| *w > 0.0);
355    if values.is_empty() {
356        return None;
357    }
358    values.sort_by(|a, b| a.0.total_cmp(&b.0));
359    let total: f32 = values.iter().map(|v| v.1).sum();
360    let mut running = 0.0;
361    for &(value, weight) in &values {
362        running += weight;
363        if running >= q * total - 1e-6 {
364            return Some(value);
365        }
366    }
367    values.last().map(|v| v.0)
368}
369
370fn median_of(mut values: Vec<f32>) -> f32 {
371    if values.is_empty() {
372        return 0.0;
373    }
374    values.sort_by(f32::total_cmp);
375    let n = values.len();
376    if n % 2 == 1 { values[n / 2] } else { (values[n / 2 - 1] + values[n / 2]) / 2.0 }
377}
378
379/// Far values pulled in to three scaled deviations of the median: real overruns
380/// stay large, a timer left running all night stops ruling the mean (TE13).
381fn winsorised(logs: &[f32]) -> Vec<f32> {
382    if logs.len() < 5 {
383        return logs.to_vec();
384    }
385    let centre = median_of(logs.to_vec());
386    let mad = 1.4826 * median_of(logs.iter().map(|y| (y - centre).abs()).collect());
387    if mad <= 0.0 {
388        return logs.to_vec();
389    }
390    logs.iter().map(|y| y.clamp(centre - 3.0 * mad, centre + 3.0 * mad)).collect()
391}
392
393/// The ratios from finished tasks, as of `today`.
394pub fn ratios(finished: &[Finished], today: Date) -> Ratios {
395    let age = |d: Date| d.until(today).map_or(0, |s| i64::from(s.get_days())).max(0);
396    let recent: Vec<&Finished> = finished.iter().filter(|f| age(f.done) <= RATIO_HORIZON && f.done <= today).collect();
397    let logs = winsorised(&recent.iter().map(|f| f.log).collect::<Vec<_>>());
398    let weight = |f: &Finished| 0.5f32.powf(age(f.done) as f32 / RATIO_HALF_LIFE) * f.weight;
399    // A weighted mean, recent tasks weighing more (EWMA by days elapsed).
400    let mean = |pick: &dyn Fn(&Finished) -> bool| -> Option<(f32, f32)> {
401        let (mut sum, mut weights, mut count) = (0.0, 0.0, 0.0);
402        for (f, y) in recent.iter().zip(&logs).filter(|(f, _)| pick(f)) {
403            let w = weight(f);
404            sum += w * y;
405            weights += w;
406            count += f.weight;
407        }
408        (weights > 0.0).then(|| (sum / weights, count))
409    };
410    let prior = PRIOR_RATIO.ln();
411    let mut out = Ratios::default();
412    if let Some((mean, count)) = mean(&|_| true) {
413        let w = (count / STANDS_FROM).min(1.0);
414        out.person = w * mean + (1.0 - w) * prior;
415        out.count = count;
416    }
417    let kinds: BTreeSet<&'static str> = recent.iter().map(|f| f.kind).collect();
418    for kind in kinds {
419        if let Some((mean, count)) = mean(&|f| f.kind == kind) {
420            let w = count / (count + KIND_PULL);
421            out.kinds.insert(kind, (w * mean + (1.0 - w) * out.person, count));
422        }
423    }
424    // The spread around each kind, robust; the prior's until ten tasks, widened for few (TE20).
425    let residuals: Vec<f32> = recent.iter().zip(&logs).map(|(f, y)| y - out.log_of(f.kind)).collect();
426    let n = residuals.len() as f32;
427    let sigma = if residuals.len() >= 10 { (1.4826 * median_of(residuals.iter().map(|r| r.abs()).collect())).clamp(0.2, 1.0) } else { PRIOR_SIGMA };
428    out.sigma = sigma * (1.0 + 1.0 / n.max(1.0)).sqrt();
429    // Today so far: what was finished today says how today goes, shrunk toward an ordinary day (TE19).
430    let spread = Spread::of(out.sigma);
431    let today_residuals: Vec<f32> = recent.iter().zip(&logs).filter(|(f, _)| f.done == today).map(|(f, y)| y - out.log_of(f.kind)).collect();
432    let k = today_residuals.len() as f32;
433    if k > 0.0 {
434        let ratio = spread.item * spread.item / (spread.day * spread.day);
435        out.today = today_residuals.iter().sum::<f32>() / (k + ratio);
436        out.today_sigma = (1.0 / (1.0 / (spread.day * spread.day) + k / (spread.item * spread.item))).sqrt();
437    } else {
438        out.today_sigma = spread.day;
439    }
440    out
441}
442
443impl Ratios {
444    /// The log ratio of a kind; yours when it has none.
445    pub fn log_of(&self, kind: &str) -> f32 {
446        self.kinds.get(kind).map_or(self.person, |k| k.0)
447    }
448
449    pub fn factor(&self, kind: &str) -> f32 {
450        self.log_of(kind).exp()
451    }
452
453    /// A task's length as the plan lays it: its estimate times its kind's
454    /// ratio. Only for a task with an estimate of its own: the person's
455    /// estimate is corrected, never replaced (TE16, TE32).
456    pub fn corrected(&self, estimate: u32, kind: &str) -> Option<u32> {
457        (estimate > 0).then(|| ((estimate as f32 * self.factor(kind)).round() as u32).max(1))
458    }
459
460    /// Today's factor on what is left of today: a day going slower makes its
461    /// steps longer; a faster one changes nothing (the plan never fills more
462    /// on the strength of one morning).
463    pub fn today_factor(&self) -> f32 {
464        self.today.max(0.0).exp()
465    }
466
467    /// The line the task panel shows on request (TE25–26): only when the kind's
468    /// ratio stands on at least five tasks and differs from 1× by 15 % or more.
469    pub fn line(&self, kind: &str, tr: &Translator) -> String {
470        let Some(&(log, count)) = self.kinds.get(kind) else { return String::new() };
471        let factor = log.exp();
472        if count < 5.0 || (factor - 1.0).abs() < 0.15 {
473            return String::new();
474        }
475        let mut args = FluentArgs::new();
476        args.set("factor", format!("{:.1}", (factor * 10.0).round() / 10.0).replace('.', if tr.language() == "fr" { "," } else { "." }));
477        tr.text(if factor > 1.0 { "capacity-ratio-longer" } else { "capacity-ratio-shorter" }, Some(&args))
478    }
479}
480
481/// A day's spread: the day's own effect, shared by its tasks, and each task's.
482#[derive(Debug, Clone, Copy, PartialEq)]
483pub struct Spread {
484    pub day: f32,
485    pub item: f32,
486}
487
488impl Spread {
489    pub fn of(sigma: f32) -> Spread {
490        Spread { day: (DAY_SHARE).sqrt() * sigma, item: (1.0 - DAY_SHARE).sqrt() * sigma }
491    }
492}
493
494/// A small, seeded generator (SplitMix64): the same seed, the same draws, on every device.
495struct Draws(u64);
496
497impl Draws {
498    fn next(&mut self) -> u64 {
499        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
500        let mut z = self.0;
501        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
502        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
503        z ^ (z >> 31)
504    }
505
506    /// Uniform in (0, 1).
507    fn uniform(&mut self) -> f64 {
508        ((self.next() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
509    }
510
511    /// Standard normal (Box–Muller).
512    fn normal(&mut self) -> f32 {
513        let (u, v) = (self.uniform(), self.uniform());
514        ((-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * v).cos()) as f32
515    }
516}
517
518/// The same free time as `slack`, by the Fenton–Wilkinson approximation of
519/// the same model (Fenton 1960; TE18): a lognormal matched to the mean and
520/// variance of the day's total, the shared day effect included. Cheap, from
521/// two sums of the steps' medians (`sum` = Σ m, `squares` = Σ m²): the plan
522/// checks with it, step by step, that a day holds its steps and their free
523/// time; the free time it keeps is then `slack`'s, on what it laid.
524pub fn slack_estimate(sum: f32, squares: f32, spread: Spread, effect: f32) -> f32 {
525    if sum <= 0.0 {
526        return 0.0;
527    }
528    let (day, item) = (spread.day * spread.day, spread.item * spread.item);
529    let mean = (effect + (day + item) / 2.0).exp() * sum;
530    // E[T²]: a step with itself, then every pair, which share the day's effect only.
531    let second = (2.0 * effect).exp() * (squares * (2.0 * day + 2.0 * item).exp() + (sum * sum - squares).max(0.0) * (2.0 * day + item).exp());
532    let variance = (second - mean * mean).max(0.0);
533    let s2 = (1.0 + variance / (mean * mean)).ln();
534    let mu = mean.ln() - s2 / 2.0;
535    ((mu + Z_DAY * s2.sqrt()).exp() - sum).max(0.0)
536}
537
538/// A seed from what the day holds: its date and its steps (FNV-1a).
539pub fn seed(date: Date, uids: &[&str]) -> u64 {
540    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
541    for byte in date.to_string().bytes().chain(uids.iter().flat_map(|u| u.bytes().chain([0]))) {
542        hash ^= u64::from(byte);
543        hash = hash.wrapping_mul(0x0100_0000_01b3);
544    }
545    hash
546}
547
548/// The free time a day keeps for its steps taking longer than their median:
549/// the 85th percentile of the day's total less the sum of the medians, by a
550/// simulation in which a shared effect moves every step of the day together
551/// (TE16–18). `effect` and `spread.day` are today's as known so far.
552pub fn slack(medians: &[u32], spread: Spread, effect: f32, seed: u64) -> u32 {
553    let sum: f32 = medians.iter().map(|&m| m as f32).sum();
554    if medians.is_empty() || sum <= 0.0 {
555        return 0;
556    }
557    let mut draws = Draws(seed);
558    let mut totals: Vec<f32> = (0..DRAWS)
559        .map(|_| {
560            let day = effect + spread.day * draws.normal();
561            medians.iter().map(|&m| m as f32 * (day + spread.item * draws.normal()).exp()).sum::<f32>()
562        })
563        .collect();
564    totals.sort_by(f32::total_cmp);
565    let p = totals[((DAY_PERCENTILE * DRAWS as f32) as usize).min(DRAWS - 1)];
566    (p - sum).max(0.0).round() as u32
567}
568
569/* ---------- Days: their load, their outcome ---------- */
570
571/// How a day went, as you said it (`reviews`): too much, about right, too empty.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "snake_case")]
574pub enum Outcome {
575    TooMuch,
576    AboutRight,
577    TooEmpty,
578}
579
580impl Outcome {
581    pub fn bad(self) -> bool {
582        self == Outcome::TooMuch
583    }
584
585    /// Fine for the costs: about right, or too empty.
586    pub fn fine(self) -> bool {
587        !self.bad()
588    }
589
590    /// Good for the gain minimum: about right only.
591    pub fn good(self) -> bool {
592        self == Outcome::AboutRight
593    }
594}
595
596/// One day of the record.
597#[derive(Debug, Clone, PartialEq)]
598pub struct DayRecord {
599    pub date: Date,
600    pub load: Load,
601    /// What is known: a cost when an item of the day was rated on it; the total when anything was recorded.
602    pub known: [bool; 5],
603    /// Σ gain/10 × hours, each item two hours at most (G8).
604    pub good_hours: f32,
605    /// Whether any item of the day carried a gain.
606    pub gain_known: bool,
607    pub outcome: Option<Outcome>,
608}
609
610/// A gain counts this long at most (G8). A guess.
611pub const GAIN_HOURS_CAP: f32 = 2.0;
612
613impl DayRecord {
614    pub fn new(date: Date) -> DayRecord {
615        DayRecord { date, load: [0.0; 5], known: [false; 5], good_hours: 0.0, gain_known: false, outcome: None }
616    }
617
618    /// Something that took `minutes` that day, rated as `rates`.
619    pub fn count(&mut self, rates: &Rates, minutes: u32) {
620        if minutes == 0 {
621            return;
622        }
623        add(&mut self.load, &rates.load(minutes));
624        for (s, known) in rates.known().iter().enumerate() {
625            self.known[s] |= *known;
626        }
627        if let Some(gain) = rates.gain {
628            self.gain_known = true;
629            self.good_hours += gain / 10.0 * (minutes as f32 / 60.0).min(GAIN_HOURS_CAP);
630        }
631    }
632
633    /// Whether anything at all is in it.
634    pub fn is_empty(&self) -> bool {
635        !self.known[TOTAL] && self.outcome.is_none()
636    }
637}
638
639/// The days from `from` to `to`, each with what it held and how it went:
640/// - tasks by the minutes worked on them that day (their sessions); a task
641///   completed that day without any session at all, by its length as planned
642///   (`corrected`, else its estimate, else `default_estimate`);
643/// - time noted for a project alone, as a usual item;
644/// - events by their length, their own ratings, else "usual";
645/// - meals, naps and sleep never (they are needs, G4).
646///
647/// A task's ratings are those said after it that day when there are some,
648/// else those the plan uses (`FeltIndex`). Days with nothing are left out.
649#[allow(clippy::too_many_arguments)]
650pub fn day_records(tasks: &[Task], sessions: &[Session], events: &[Occurrence], index: &FeltIndex, corrected: &dyn Fn(&Task) -> u32, outcomes: &BTreeMap<Date, Outcome>, from: Date, to: Date, zone: &TimeZone) -> Vec<DayRecord> {
651    let day_of = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
652    let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
653    let mut days: BTreeMap<Date, DayRecord> = BTreeMap::new();
654    fn record(days: &mut BTreeMap<Date, DayRecord>, date: Date, from: Date, to: Date) -> Option<&mut DayRecord> {
655        (date >= from && date <= to).then(|| days.entry(date).or_insert_with(|| DayRecord::new(date)))
656    }
657    // The ratings of a task that day: said after it that day, else the plan's.
658    let rates_on = |task: &Task, date: Date| -> Rates {
659        let planned = index.rates(task);
660        match task.felt.iter().find(|f| f.on == Some(date)) {
661            Some(felt) => {
662                let said = Rates::of(&felt.demands, planned.word);
663                Rates { costs: std::array::from_fn(|s| said.costs[s].or(planned.costs[s])), gain: said.gain.or(planned.gain), word: planned.word }
664            }
665            None => planned,
666        }
667    };
668    let mut worked: BTreeSet<&str> = BTreeSet::new();
669    let mut minutes: BTreeMap<(Date, &str), u32> = BTreeMap::new();
670    for s in sessions {
671        let Some(date) = day_of(s.start) else { continue };
672        if !s.task.is_empty() {
673            worked.insert(s.task.as_str());
674        }
675        let key = (date, if s.task.is_empty() { "" } else { s.task.as_str() });
676        *minutes.entry(key).or_default() += s.minutes;
677    }
678    for ((date, uid), m) in minutes {
679        let Some(day) = record(&mut days, date, from, to) else { continue };
680        match by_uid.get(uid) {
681            Some(task) => day.count(&rates_on(task, date), m),
682            None => day.count(&Rates::default(), m),
683        }
684    }
685    for task in tasks.iter().filter(|t| t.status == Status::Completed && !worked.contains(t.uid.as_str())) {
686        let Some(date) = task.completed.and_then(day_of) else { continue };
687        let Some(day) = record(&mut days, date, from, to) else { continue };
688        day.count(&rates_on(task, date), corrected(task));
689    }
690    // Events that held time (`Occurrence::holds_time`): not one you declined, nor a transparent one.
691    for event in events.iter().filter(|e| e.holds_time() && !e.all_day && e.end > e.start) {
692        let Some(date) = day_of(event.start) else { continue };
693        let Some(day) = record(&mut days, date, from, to) else { continue };
694        let length = u32::try_from((event.end - event.start) / 60).unwrap_or(0).min(24 * 60);
695        day.count(&Rates::of_event(event), length);
696    }
697    for (&date, &outcome) in outcomes.range(from..=to) {
698        if let Some(day) = record(&mut days, date, from, to) {
699            day.outcome = Some(outcome);
700        }
701    }
702    days.into_values().filter(|d| !d.is_empty()).collect()
703}
704
705/* ---------- Budgets: learned from the days whose outcome you gave ---------- */
706
707/// Where the budgets start, before your days say more (criterion 24).
708#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "kebab-case")]
710pub enum Start {
711    /// As the plan was: your hours full of usual work, and two heavy hours.
712    #[default]
713    AsNow,
714    /// About three quarters of that.
715    Lighter,
716    /// About half: the advice for energy-limiting illness (start at half, #MEAction).
717    MuchLighter,
718}
719
720impl Start {
721    pub fn share(self) -> f32 {
722        match self {
723            Start::AsNow => 1.0,
724            Start::Lighter => 0.75,
725            Start::MuchLighter => 0.5,
726        }
727    }
728
729    pub fn parse(text: &str) -> Start {
730        match text {
731            "lighter" => Start::Lighter,
732            "much-lighter" => Start::MuchLighter,
733            _ => Start::AsNow,
734        }
735    }
736}
737
738/// The budgets to start from: a day of `room` minutes (the fullest day of
739/// your week) full of usual work (4 an hour), and two heavy hours (7 instead
740/// of 4), fitting under the 85 % the plan fills, times the start chosen. Each
741/// cost starts at the total: a cost of an item never passes its highest, so
742/// a cost's budget binds only once learning has lowered it.
743pub fn start_budget(room: u32, start: Start) -> Load {
744    let hours = room as f32 / 60.0;
745    let total = (Level::Usual.rating() * hours + 2.0 * (Level::Heavy.rating() - Level::Usual.rating())) / FILL * start.share();
746    [total.max(1.0); 5]
747}
748
749/// Days for a day's weight to halve in the budgets' mean (criterion 17).
750pub const BUDGET_HALF_LIFE: f32 = 5.0;
751/// A good day informs the budget from this share of it used (criterion 15a). A guess.
752pub const INFORMATIVE: f32 = 0.7;
753/// A day this full, followed by a bad one, lowers the budget (criterion 20). A guess.
754pub const FULL: f32 = 0.8;
755/// By how much a budget falls at once (criterion 20). A guess.
756pub const DROP: f32 = 0.8;
757/// By how much a budget may rise in a week (criterion 19). A guess.
758pub const RISE: f32 = 1.1;
759/// Days looked back to blame a bad day on a full one (criterion 20: 1–3). A guess.
760pub const BLAME_DAYS: i64 = 2;
761/// Stable days asked before a rise, and between two changes (criterion 19).
762pub const STABLE_DAYS: usize = 7;
763/// Bad days within seven that freeze rises (criterion 21).
764pub const FLARE: usize = 3;
765/// Days with an outcome before what is learned has full weight (criterion 24).
766pub const FULL_WEIGHT_DAYS: f32 = 14.0;
767/// A budget never falls below this share of its start. A guess.
768pub const FLOOR: f32 = 0.25;
769/// One day's load counts at most this much of the budget (criterion 23).
770pub const WINSOR: f32 = 1.2;
771/// Days looked back for the heaviest day well tolerated (criterion 22).
772pub const CAP_DAYS: i64 = 30;
773/// A cost needs this many fine days with load on it before its cap applies. A guess.
774pub const CAP_FROM: usize = 3;
775/// The gain minimum, in good hours: before data, and its bounds (G9–G11).
776pub const GAIN_DEFAULT: f32 = 1.0;
777pub const GAIN_LOWEST: f32 = 0.5;
778pub const GAIN_HIGHEST: f32 = 4.0;
779/// Good days asked before the minimum is learned (G10).
780pub const GAIN_FROM: usize = 5;
781/// The minimum is this percentile of good days' good hours (G9). A guess.
782pub const GAIN_PERCENTILE: f32 = 0.25;
783/// The learning window, in days: its default and bounds (the person's setting).
784pub const WINDOW_DEFAULT: u32 = 28;
785pub const WINDOW_LEAST: u32 = 14;
786pub const WINDOW_MOST: u32 = 90;
787
788/// A change a rule made to a budget.
789#[derive(Debug, Clone, Copy, PartialEq)]
790pub struct Change {
791    pub date: Date,
792    pub scale: usize,
793    /// The budget after it over the budget before.
794    pub factor: f32,
795}
796
797/// What the record says, as of a day.
798#[derive(Debug, Clone, PartialEq)]
799pub struct Learned {
800    pub budget: Load,
801    pub start: Load,
802    /// The heaviest well-tolerated day of the last 30, plus 10 %, per cost; None until known.
803    pub cap: [Option<f32>; 5],
804    /// Rises held back after a flare.
805    pub frozen: bool,
806    /// The gain minimum, in good hours.
807    pub minimum: f32,
808    /// Days with an outcome within the window.
809    pub days: usize,
810    pub changes: Vec<Change>,
811}
812
813fn days_between(a: Date, b: Date) -> i64 {
814    a.until(b).map_or(0, |s| i64::from(s.get_days()))
815}
816
817/// The budgets and the gain minimum as of `today`, replayed day by day over
818/// `records` (oldest first; days before `today` only), from `start`, with
819/// `window` days of memory. The rules, in order, on each day with an outcome:
820/// a drop when it was bad after a full day; a freeze after a flare; the
821/// target from informative good days; a rise after a stable week
822/// (docs/capacity.md, "Budgets").
823pub fn learn(records: &[DayRecord], start: Load, window: u32, today: Date) -> Learned {
824    let window = i64::from(window.clamp(WINDOW_LEAST, WINDOW_MOST));
825    let records: Vec<&DayRecord> = records.iter().filter(|r| r.date < today).collect();
826    let at: BTreeMap<Date, usize> = records.iter().enumerate().map(|(i, r)| (r.date, i)).collect();
827    let floor = start.map(|b| b * FLOOR);
828    let mut budget = start;
829    let mut in_force: Vec<Load> = Vec::with_capacity(records.len());
830    let mut last_change: [Option<Date>; 5] = [None; 5];
831    let mut blamed: BTreeSet<(Date, usize)> = BTreeSet::new();
832    let mut frozen = false;
833    let mut minimum = GAIN_DEFAULT;
834    let mut changes: Vec<Change> = Vec::new();
835    let mut with_outcome: Vec<usize> = Vec::new();
836    let used = |r: &DayRecord, b: &Load, s: usize| if b[s] > 0.0 { r.load[s] / b[s] } else { 0.0 };
837    for (i, record) in records.iter().enumerate() {
838        in_force.push(budget);
839        let Some(outcome) = record.outcome else { continue };
840        with_outcome.push(i);
841        let d = record.date;
842        // 1. A bad day after a full one: that cost falls by a fifth, once per full day.
843        if outcome.bad() {
844            for s in 0..5 {
845                for back in 1..=BLAME_DAYS {
846                    let Some(&j) = d.checked_sub(jiff::Span::new().days(back)).ok().and_then(|p| at.get(&p)) else { continue };
847                    let prior = records[j];
848                    if prior.known[s] && used(prior, &in_force[j], s) >= FULL && blamed.insert((prior.date, s)) {
849                        let before = budget[s];
850                        budget[s] = (budget[s] * DROP).max(floor[s]);
851                        last_change[s] = Some(d);
852                        changes.push(Change { date: d, scale: s, factor: budget[s] / before });
853                        break;
854                    }
855                }
856            }
857        }
858        // 2. A flare (three bad days in seven) freezes rises, until seven days with an outcome and none bad.
859        let recent_bad = with_outcome.iter().filter(|&&j| days_between(records[j].date, d) < 7 && records[j].outcome.is_some_and(Outcome::bad)).count();
860        let last_week: Vec<usize> = with_outcome.iter().rev().take(STABLE_DAYS).copied().collect();
861        if recent_bad >= FLARE {
862            frozen = true;
863        } else if frozen && last_week.len() == STABLE_DAYS && last_week.iter().all(|&j| records[j].outcome.is_some_and(Outcome::fine)) {
864            frozen = false;
865        }
866        // 3–5. Rises: toward what good, full days showed, a tenth a week at most, after a stable week.
867        let in_window = |j: usize| days_between(records[j].date, d) < window;
868        let outcome_days = with_outcome.iter().filter(|&&j| in_window(j)).count() as f32;
869        let alpha = (outcome_days / FULL_WEIGHT_DAYS).min(1.0);
870        for s in 0..5 {
871            if frozen || last_change[s].is_some_and(|c| days_between(c, d) < STABLE_DAYS as i64) {
872                continue;
873            }
874            let stable = last_week.len() == STABLE_DAYS
875                && last_week.iter().all(|&j| in_window(j) && records[j].outcome.is_some_and(Outcome::fine))
876                && last_week.iter().map(|&j| used(records[j], &in_force[j], s)).sum::<f32>() / STABLE_DAYS as f32 >= FULL;
877            if !stable {
878                continue;
879            }
880            let (mut sum, mut weights) = (0.0, 0.0);
881            for &j in with_outcome.iter().filter(|&&j| in_window(j)) {
882                let r = records[j];
883                if r.known[s] && r.outcome.is_some_and(Outcome::fine) && used(r, &in_force[j], s) >= INFORMATIVE {
884                    let w = 0.5f32.powf(days_between(r.date, d) as f32 / BUDGET_HALF_LIFE);
885                    sum += w * r.load[s].min(WINSOR * in_force[j][s]);
886                    weights += w;
887                }
888            }
889            if weights <= 0.0 {
890                continue;
891            }
892            let target = (1.0 - alpha) * start[s] + alpha * sum / weights;
893            if target > budget[s] {
894                let before = budget[s];
895                budget[s] = target.min(before * RISE);
896                last_change[s] = Some(d);
897                changes.push(Change { date: d, scale: s, factor: budget[s] / before });
898            }
899        }
900        // The gain minimum: the 25th percentile of good days, never learned downward from a bad stretch.
901        let good: Vec<(f32, f32)> = with_outcome
902            .iter()
903            .filter(|&&j| in_window(j))
904            .map(|&j| records[j])
905            .filter(|r| r.outcome.is_some_and(Outcome::good) && r.gain_known)
906            .map(|r| (r.good_hours, 0.5f32.powf(days_between(r.date, d) as f32 / BUDGET_HALF_LIFE)))
907            .collect();
908        let candidate = if good.len() < GAIN_FROM { GAIN_DEFAULT } else { weighted_quantile(good, GAIN_PERCENTILE).unwrap_or(GAIN_DEFAULT).clamp(GAIN_LOWEST, GAIN_HIGHEST) };
909        let bad_stretch = last_week.iter().filter(|&&j| !records[j].outcome.is_some_and(Outcome::good)).count() >= FLARE;
910        if !(candidate < minimum && bad_stretch) {
911            minimum = candidate;
912        }
913    }
914    // The cap: the heaviest fine day of the last thirty, plus a tenth, once three such days are known.
915    let cap = std::array::from_fn(|s| {
916        let fine: Vec<f32> = records.iter().filter(|r| days_between(r.date, today) <= CAP_DAYS && r.known[s] && r.load[s] > 0.0 && r.outcome.is_some_and(Outcome::fine)).map(|r| r.load[s]).collect();
917        (fine.len() >= CAP_FROM).then(|| fine.iter().copied().fold(0.0, f32::max) * RISE)
918    });
919    let days = with_outcome.iter().filter(|&&j| days_between(records[j].date, today) <= window).count();
920    Learned { budget, start, cap, frozen, minimum, days, changes }
921}
922
923impl Learned {
924    /// A day's limit per cost before the fill: the budget, under its cap.
925    pub fn limit(&self) -> Load {
926        std::array::from_fn(|s| self.cap[s].map_or(self.budget[s], |cap| self.budget[s].min(cap)))
927    }
928}
929
930/* ---------- What the plan is given ---------- */
931
932/// What the plan needs from the record (`plan::Settings::capacity`). Its
933/// default changes nothing: no budget, no correction, no slack, no gain slot.
934#[derive(Debug, Clone, Default)]
935pub struct Planning {
936    /// The ratings each task is planned with, by UID (`FeltIndex::rates`).
937    pub rates: BTreeMap<String, Rates>,
938    /// Each task's length as the plan lays it, by UID (`Ratios::corrected`).
939    pub corrected: BTreeMap<String, u32>,
940    /// What events already hold each day.
941    pub held: BTreeMap<Date, Load>,
942    /// Days before and after a heavy event: lighter.
943    pub lightened: BTreeSet<Date>,
944    /// Each day's limit per cost before the fill, the cap applied; None: no budget.
945    pub limit: Option<Load>,
946    /// Days kept even (criterion 35).
947    pub even: bool,
948    /// The spread for the day's free time; None: none kept.
949    pub spread: Option<Spread>,
950    /// Today's effect so far (log), and its spread (`Ratios::today`, `today_sigma`).
951    pub today_effect: f32,
952    pub today_sigma: f32,
953    /// Minutes of each day's room kept for the gain slot after its costliest block.
954    pub gain_slot: u32,
955}
956
957impl Planning {
958    /// The ratings a task is planned with.
959    pub fn rates_of(&self, task: &Task) -> Rates {
960        self.rates.get(&task.uid).copied().unwrap_or_else(|| Rates::of_task(task))
961    }
962
963    pub fn level_of(&self, task: &Task) -> Level {
964        self.rates.get(&task.uid).map_or_else(|| level_of(task), Rates::level)
965    }
966
967    /// A day's limit per cost: the budget's fill, its weather (`share`, today's), lighter around a heavy event.
968    pub fn day_limit(&self, date: Date, share: f32) -> Option<Load> {
969        let around = if self.lightened.contains(&date) { AROUND_HEAVY } else { 1.0 };
970        self.limit.map(|l| l.map(|b| b * FILL * share * around))
971    }
972}
973
974/// The days before and after each heavy event (its highest cost 7 or more), from `today` on.
975pub fn around_heavy(events: &[Occurrence], zone: &TimeZone, today: Date) -> BTreeSet<Date> {
976    let mut out = BTreeSet::new();
977    for event in events.iter().filter(|e| e.holds_time() && e.demands.highest().is_some_and(|h| h >= HEAVY_EVENT)) {
978        let Some(first) = jiff::Timestamp::from_second(event.start).ok().map(|t| t.to_zoned(zone.clone()).date()) else { continue };
979        let last = jiff::Timestamp::from_second((event.end - 1).max(event.start)).ok().map_or(first, |t| t.to_zoned(zone.clone()).date());
980        for date in [first.yesterday().ok(), last.tomorrow().ok()].into_iter().flatten() {
981            if date >= today {
982                out.insert(date);
983            }
984        }
985    }
986    out
987}
988
989/// What each day's events hold, by day: those that hold time (`Occurrence::holds_time`).
990pub fn held_by_events(events: &[Occurrence], zone: &TimeZone) -> BTreeMap<Date, Load> {
991    let mut out: BTreeMap<Date, Load> = BTreeMap::new();
992    for event in events.iter().filter(|e| e.holds_time() && !e.all_day && e.end > e.start) {
993        let Some(date) = jiff::Timestamp::from_second(event.start).ok().map(|t| t.to_zoned(zone.clone()).date()) else { continue };
994        let minutes = u32::try_from((event.end - event.start) / 60).unwrap_or(0).min(24 * 60);
995        add(out.entry(date).or_insert([0.0; 5]), &Rates::of_event(event).load(minutes));
996    }
997    out
998}
999
1000/* ---------- Gathered from the record ---------- */
1001
1002/// How far back the record is read to replay the budgets.
1003pub const HISTORY_DAYS: i64 = 180;
1004/// How far ahead events weigh on the plan's days (as the plan's room does).
1005pub const AHEAD_DAYS: i64 = 28;
1006/// Minutes kept each day for the gain slot after its costliest block (G11). A guess.
1007pub const GAIN_SLOT_MINUTES: u32 = 30;
1008
1009/// The days' outcomes, as the reviews say them: the night's mix, else the work review's.
1010pub fn outcomes_of(reviews: &crate::reviews::Reviews) -> BTreeMap<Date, Outcome> {
1011    use crate::reviews::Mix;
1012    reviews
1013        .days
1014        .iter()
1015        .filter_map(|(date, day)| {
1016            let outcome = match day.outcome()? {
1017                Mix::TooMuch => Outcome::TooMuch,
1018                Mix::AboutRight => Outcome::AboutRight,
1019                Mix::TooEmpty => Outcome::TooEmpty,
1020            };
1021            Some((*date, outcome))
1022        })
1023        .collect()
1024}
1025
1026/// What a task is for, as the ratio sorts tasks: its area, three kinds, always
1027/// known (TE10: few kinds; docs/capacity.md, "Corrected estimates").
1028pub fn ratio_kind(areas: &crate::areas::TaskAreas, task: &Task) -> &'static str {
1029    let area = areas.of(task);
1030    if area.work {
1031        "work"
1032    } else if area.admin {
1033        "admin"
1034    } else {
1035        "leisure"
1036    }
1037}
1038
1039/// Everything the plan and the days' balance need from your record.
1040#[derive(Debug, Clone)]
1041pub struct Record {
1042    pub index: FeltIndex,
1043    pub ratios: Ratios,
1044    pub learned: Learned,
1045    /// The days before today, with what they held and how they went.
1046    pub records: Vec<DayRecord>,
1047    pub outcomes: BTreeMap<Date, Outcome>,
1048    /// What the plan is given.
1049    pub planning: Planning,
1050    /// A heavy event tomorrow (true) or yesterday (false): its title, for the reason in words.
1051    pub heavy_near: Option<(String, bool)>,
1052    /// The learning window, in days.
1053    pub window: u32,
1054    /// Minutes counted for a task without an estimate.
1055    pub default_estimate: u32,
1056    /// Each task's kind for the ratio (its area), by UID.
1057    pub kinds: BTreeMap<String, &'static str>,
1058}
1059
1060fn midnight_of(date: Date, zone: &TimeZone) -> i64 {
1061    date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
1062}
1063
1064/// The record read and weighed as of `now`: the ratings said after tasks, the
1065/// ratio of time spent to first guesses, the days before (their tasks,
1066/// sessions, events and outcomes) with the budgets replayed over them, the
1067/// events of the coming weeks; and what the plan is given (`Planning`).
1068/// Events of the past are read only once an outcome was given; `events`
1069/// reads those between two instants (`agenda::occurrences`, or a cache of it).
1070pub fn gather(tasks: &[Task], sessions: &[Session], settings: &crate::plan::Settings, choices: &crate::config::PlanningSettings, events: &dyn Fn(i64, i64) -> Vec<Occurrence>, now: &jiff::Zoned) -> Record {
1071    let zone = now.time_zone().clone();
1072    let today = now.date();
1073    let index = FeltIndex::of(tasks);
1074    let kind = |t: &Task| ratio_kind(&settings.areas, t);
1075    let ratios = ratios(&finished(tasks, sessions, &kind, &zone), today);
1076    let length = |t: &Task| ratios.corrected(t.estimate, kind(t)).unwrap_or(if t.estimate > 0 { t.estimate } else { settings.default_estimate });
1077    let from = today.checked_sub(jiff::Span::new().days(HISTORY_DAYS)).unwrap_or(today);
1078    let outcomes = outcomes_of(&crate::reviews::Reviews::load_between(&crate::reviews::Reviews::default_path(), from, today));
1079    let records = match outcomes.keys().next() {
1080        Some(first) => {
1081            let start = first.checked_sub(jiff::Span::new().days(BLAME_DAYS)).unwrap_or(*first).max(from);
1082            let past = events(midnight_of(start, &zone), midnight_of(today, &zone));
1083            day_records(tasks, sessions, &past, &index, &length, &outcomes, start, today.yesterday().unwrap_or(today), &zone)
1084        }
1085        None => Vec::new(),
1086    };
1087    let room = settings.week.iter().map(crate::plan::Room::total).max().unwrap_or(0);
1088    let learned = learn(&records, start_budget(room, choices.start()), choices.window(), today);
1089    // From yesterday: a heavy event then lightens today.
1090    let midnight = midnight_of(today, &zone);
1091    let ahead = events(midnight - 86_400, midnight + AHEAD_DAYS * 86_400);
1092    let lightened = around_heavy(&ahead, &zone, today);
1093    let heavy = |e: &&Occurrence| !e.cancelled && e.demands.highest().is_some_and(|h| h >= HEAVY_EVENT);
1094    let day_of = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
1095    let heavy_near = ahead
1096        .iter()
1097        .filter(heavy)
1098        .find_map(|e| (day_of(e.start) == today.tomorrow().ok()).then(|| (e.summary.clone(), true)))
1099        .or_else(|| ahead.iter().filter(heavy).find_map(|e| (day_of((e.end - 1).max(e.start)) == today.yesterday().ok()).then(|| (e.summary.clone(), false))));
1100    let open: Vec<&Task> = tasks.iter().filter(|t| t.status.is_open()).collect();
1101    let planning = Planning {
1102        rates: open.iter().map(|t| (t.uid.clone(), index.rates(t))).collect(),
1103        corrected: open.iter().filter_map(|t| Some((t.uid.clone(), ratios.corrected(t.estimate, kind(t))?))).collect(),
1104        held: held_by_events(&ahead, &zone),
1105        lightened,
1106        limit: Some(learned.limit()),
1107        even: choices.even_days,
1108        spread: Some(Spread::of(ratios.sigma)),
1109        today_effect: ratios.today,
1110        today_sigma: ratios.today_sigma,
1111        gain_slot: if choices.gain_slots() { GAIN_SLOT_MINUTES } else { 0 },
1112    };
1113    Record { index, ratios, learned, records, outcomes, planning, heavy_near, window: choices.window(), default_estimate: settings.default_estimate, kinds: tasks.iter().map(|t| (t.uid.clone(), kind(t))).collect() }
1114}
1115
1116impl Record {
1117    /// A task's length as the plan counts it: its corrected estimate, else its estimate, else the default.
1118    fn length(&self, task: &Task) -> u32 {
1119        let kind = self.kinds.get(&task.uid).copied().unwrap_or("leisure");
1120        self.ratios.corrected(task.estimate, kind).unwrap_or(if task.estimate > 0 { task.estimate } else { self.default_estimate })
1121    }
1122
1123    /// The line the task panel shows on request: how tasks like this one usually go (`Ratios::line`).
1124    pub fn ratio_line(&self, task: &Task, tr: &Translator) -> String {
1125        self.ratios.line(self.kinds.get(&task.uid).copied().unwrap_or("leisure"), tr)
1126    }
1127
1128    /// A day's balance in words (for the evening's review): what it held
1129    /// against what a day holds, as learned before it, its gain against the
1130    /// minimum, and what learning from it changes for the next day. `events`
1131    /// are that day's.
1132    pub fn day_balance(&self, tasks: &[Task], sessions: &[Session], events: &[Occurrence], date: Date, zone: &TimeZone, tr: &Translator) -> DayBalance {
1133        let day = day_records(tasks, sessions, events, &self.index, &|t| self.length(t), &self.outcomes, date, date, zone).pop().unwrap_or_else(|| DayRecord { outcome: self.outcomes.get(&date).copied(), ..DayRecord::new(date) });
1134        let before: Vec<DayRecord> = self.records.iter().filter(|r| r.date < date).cloned().collect();
1135        let learned = learn(&before, self.learned.start, self.window, date);
1136        let mut with: Vec<DayRecord> = before;
1137        with.push(day.clone());
1138        let after = date.tomorrow().ok().map(|next| learn(&with, self.learned.start, self.window, next));
1139        balance(&day, &learned, after.as_ref().filter(|_| day.outcome.is_some()), tr)
1140    }
1141
1142    /// Why today holds what it holds, in at most two lines (`reasons`).
1143    pub fn reasons(&self, today: Date, tr: &Translator) -> Vec<String> {
1144        reasons(&self.learned, today, &self.planning.lightened, self.heavy_near.as_ref().map(|(t, before)| (t.as_str(), *before)), tr)
1145    }
1146}
1147
1148/* ---------- In words ---------- */
1149
1150/// A cost of a day, in a word.
1151#[derive(Debug, Clone, PartialEq, Serialize)]
1152pub struct ScaleBalance {
1153    /// "cognitive", "emotional", "anxiety", "body", "total".
1154    pub scale: &'static str,
1155    /// "light", "usual", "heavy"; "" when nothing is known of it that day.
1156    pub level: &'static str,
1157    /// Not to be shown: for tests and for the record.
1158    pub load: f32,
1159    pub budget: f32,
1160}
1161
1162/// What gave back that day, in a word.
1163#[derive(Debug, Clone, PartialEq, Serialize)]
1164pub struct GainBalance {
1165    /// "below", "around", "above" the minimum; "" when nothing carried a gain.
1166    pub level: &'static str,
1167    /// Not to be shown.
1168    pub good_hours: f32,
1169    pub minimum: f32,
1170}
1171
1172/// A day's balance, in words: each cost and the total against what the day
1173/// holds, the gain against its minimum, and what the plan does with it.
1174#[derive(Debug, Clone, PartialEq, Serialize)]
1175pub struct DayBalance {
1176    pub date: String,
1177    pub costs: Vec<ScaleBalance>,
1178    pub gain: GainBalance,
1179    /// At most two lines, with their reason; nothing to say, none.
1180    pub said: Vec<String>,
1181}
1182
1183/// A load this share of the budget or less is a light day; above the budget, a heavy one. Guesses.
1184pub const LIGHT_DAY: f32 = 0.5;
1185pub const HEAVY_DAY: f32 = 1.0;
1186
1187/// The balance of `record`'s day against `learned` (as of that day), and what
1188/// learning from it would change (`after`: as of the next day; None when not known).
1189pub fn balance(record: &DayRecord, learned: &Learned, after: Option<&Learned>, tr: &Translator) -> DayBalance {
1190    let limit = learned.limit();
1191    let costs = (0..5)
1192        .map(|s| {
1193            let used = if limit[s] > 0.0 { record.load[s] / limit[s] } else { 0.0 };
1194            let level = if !record.known[s] {
1195                ""
1196            } else if used > HEAVY_DAY {
1197                "heavy"
1198            } else if used <= LIGHT_DAY && !record.outcome.is_some_and(Outcome::bad) {
1199                "light"
1200            } else {
1201                "usual"
1202            };
1203            ScaleBalance { scale: SCALES[s], level, load: record.load[s], budget: limit[s] }
1204        })
1205        .collect();
1206    let minimum = learned.minimum;
1207    let gain_level = if !record.gain_known {
1208        ""
1209    } else if record.good_hours < 0.75 * minimum {
1210        "below"
1211    } else if record.good_hours > 1.25 * minimum {
1212        "above"
1213    } else {
1214        "around"
1215    };
1216    let mut said = Vec::new();
1217    if let Some(after) = after {
1218        if (0..5).any(|s| after.budget[s] < learned.budget[s] * 0.99) {
1219            said.push(tr.text("capacity-said-tomorrow-less", None));
1220        }
1221        if after.frozen && !learned.frozen {
1222            said.push(tr.text("capacity-said-held", None));
1223        }
1224    }
1225    said.truncate(2);
1226    DayBalance { date: record.date.to_string(), costs, gain: GainBalance { level: gain_level, good_hours: record.good_hours, minimum }, said }
1227}
1228
1229/// Why today holds what it holds, in at most two lines: a budget lowered
1230/// yesterday after a bad day that followed a full one; a heavy event the day
1231/// before or after; a change against two weeks ago once it passes 30 %
1232/// (criterion 48). Nothing when nothing changed the day.
1233pub fn reasons(learned: &Learned, today: Date, lightened: &BTreeSet<Date>, heavy_event: Option<(&str, bool)>, tr: &Translator) -> Vec<String> {
1234    let mut out = Vec::new();
1235    let yesterday = today.yesterday().unwrap_or(today);
1236    if learned.changes.iter().any(|c| c.date == yesterday && c.factor < 1.0) {
1237        out.push(tr.text("capacity-reason-after-bad", None));
1238    }
1239    if lightened.contains(&today)
1240        && let Some((title, before)) = heavy_event
1241    {
1242        let mut args = FluentArgs::new();
1243        args.set("title", title.to_string());
1244        out.push(tr.text(if before { "capacity-reason-before-heavy" } else { "capacity-reason-after-heavy" }, Some(&args)));
1245    }
1246    // Against two weeks ago, the total only, past noise.
1247    let fortnight = today.checked_sub(jiff::Span::new().days(14)).unwrap_or(today);
1248    let product: f32 = learned.changes.iter().filter(|c| c.scale == TOTAL && c.date > fortnight).map(|c| c.factor).product();
1249    if product >= 1.3 {
1250        out.push(tr.text("capacity-reason-more", None));
1251    } else if product <= 1.0 / 1.3 && out.is_empty() {
1252        out.push(tr.text("capacity-reason-less", None));
1253    }
1254    out.truncate(2);
1255    out
1256}
1257
1258/* ---------- The gain: suggestions from your own items ---------- */
1259
1260/// An item you rated as giving back well (felt gain 6 or more, no felt cost
1261/// above 3), rotated by day: what a gain slot may suggest (G19). Never one
1262/// you did not rate yourself, never a social one by default (G21).
1263pub fn suggestion(tasks: &[Task], index: &FeltIndex, today: Date) -> Option<String> {
1264    let mut seen: BTreeSet<String> = BTreeSet::new();
1265    let mut liked: Vec<&Task> = tasks
1266        .iter()
1267        .filter(|t| !t.felt.is_empty())
1268        .filter(|t| {
1269            let item = index.items.get(&item_key(t)).map(Vec::as_slice).unwrap_or(&[]);
1270            let gain = FeltIndex::median(item, |d| d.gain);
1271            let costs = [FeltIndex::median(item, |d| d.cognitive), FeltIndex::median(item, |d| d.emotional), FeltIndex::median(item, |d| d.anxiety), FeltIndex::median(item, |d| d.body)];
1272            gain.is_some_and(|g| g >= 6.0) && costs.iter().flatten().all(|c| *c <= 3.0)
1273        })
1274        .filter(|t| seen.insert(item_key(t)))
1275        .collect();
1276    if liked.is_empty() {
1277        return None;
1278    }
1279    liked.sort_by_key(|t| item_key(t));
1280    let day = Date::constant(2000, 1, 1).until(today).map_or(0, |s| s.get_days().unsigned_abs() as usize);
1281    Some(liked[day % liked.len()].title.clone())
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use crate::demands::Felt;
1288
1289    fn day(text: &str) -> Date {
1290        text.parse().unwrap()
1291    }
1292
1293    fn rated(c: Option<u8>, e: Option<u8>, a: Option<u8>, b: Option<u8>, g: Option<u8>) -> Demands {
1294        Demands { cognitive: c, emotional: e, anxiety: a, body: b, gain: g }
1295    }
1296
1297    #[test]
1298    fn loads_rating_by_hours_half_an_hour_at_least() {
1299        let call = Rates::of(&rated(None, None, Some(8), None, None), Level::Usual);
1300        // A ten-minute call counts half an hour: 8 × 0.5.
1301        assert_eq!(call.load(10), [0.0, 0.0, 4.0, 0.0, 4.0]);
1302        // Unrated: its word, in the total only (light 2, usual 4, heavy 7).
1303        assert_eq!(Rates::of(&Demands::default(), Level::Heavy).load(60), [0.0, 0.0, 0.0, 0.0, 7.0]);
1304        assert_eq!(Rates::of(&Demands::default(), Level::Usual).load(90), [0.0, 0.0, 0.0, 0.0, 6.0]);
1305        assert_eq!(Rates::of(&Demands::default(), Level::Rest).load(60)[TOTAL], 2.0, "what gives back is not free");
1306        // The total follows the highest cost, never their sum.
1307        let mixed = Rates::of(&rated(Some(5), Some(2), Some(3), Some(1), None), Level::Usual);
1308        assert_eq!(mixed.load(60), [5.0, 2.0, 3.0, 1.0, 5.0]);
1309        assert_eq!((mixed.dominant(), Rates::of(&rated(Some(3), Some(2), None, None, None), Level::Light).dominant()), (Some(0), None));
1310    }
1311
1312    fn felt_task(uid: &str, title: &str, kind: &str, forecast: Demands, felt: &[(&str, Demands)]) -> Task {
1313        Task { uid: uid.into(), title: title.into(), kind: kind.into(), demands: forecast, felt: felt.iter().map(|(on, d)| Felt { on: Some(day(on)), demands: *d }).collect(), ..Task::default() }
1314    }
1315
1316    #[test]
1317    fn felt_ratings_plan_over_forecasts() {
1318        let anxious = rated(None, None, Some(9), None, None);
1319        // The same call made again each week: felt 3, 4, 2, 5, 3, 8 (the last five: 4, 2, 5, 3, 8).
1320        let history: Vec<(&str, Demands)> = [("2026-09-01", 3), ("2026-09-08", 4), ("2026-09-15", 2), ("2026-09-22", 5), ("2026-09-29", 3), ("2026-10-06", 8)].iter().map(|(d, a)| (*d, rated(None, None, Some(*a), None, None))).collect();
1321        let old = felt_task("old", "Call the bank", "call", anxious, &history);
1322        let new = felt_task("new", " call the BANK ", "call", anxious, &[]);
1323        let other = felt_task("other", "Call the school", "call", rated(Some(2), None, None, None, None), &[]);
1324        let index = FeltIndex::of(&[old, new.clone(), other.clone()]);
1325        // The item's median of its last five: 4.
1326        assert_eq!(index.rates(&new).costs[2], Some(4.0));
1327        // Another call: its own forecast first; what it leaves unsaid, from calls as felt.
1328        let rates = index.rates(&other);
1329        assert_eq!((rates.costs[0], rates.costs[2]), (Some(2.0), Some(4.0)));
1330        // Nothing felt at all: the forecast.
1331        assert_eq!(FeltIndex::default().rates(&new).costs[2], Some(9.0));
1332        assert_eq!(index.rates(&new).level(), Level::Usual, "felt: usual, though forecast heavy");
1333    }
1334
1335    #[test]
1336    fn proposals_come_from_what_was_felt_never_from_the_forecast() {
1337        // The same call felt twice (worry 3, then 4: a median of 3.5), its gain once; another call felt for thinking.
1338        let old = felt_task("old", "Call the bank", "call", rated(None, None, Some(9), None, None), &[("2026-09-01", rated(None, None, Some(3), None, Some(6))), ("2026-09-08", rated(None, None, Some(4), None, None))]);
1339        let school = felt_task("school", "Call the school", "call", Demands::default(), &[("2026-09-02", rated(Some(2), None, Some(8), None, None))]);
1340        let new = felt_task("new", "call the bank", "call", rated(Some(7), None, Some(9), None, None), &[]);
1341        let index = FeltIndex::of(&[old, school, new.clone()]);
1342        // Worry and gain from the same call (3.5 rounded up, not the kind's 8); thinking from calls; never the forecast's 7 or 9.
1343        let proposal = index.proposal(&new);
1344        assert_eq!(proposal.values, rated(Some(2), None, Some(4), None, Some(6)));
1345        assert_eq!(proposal.from, "both");
1346        // Another call, never felt: calls as felt (worry: 3, 8, 4, a median of 4).
1347        let plumber = felt_task("plumber", "Call the plumber", "call", Demands::default(), &[]);
1348        assert_eq!(index.proposal(&plumber), Proposal { values: rated(Some(2), None, Some(4), None, Some(6)), from: "kind" });
1349        // Another kind, another title: nothing to propose.
1350        let letter = felt_task("letter", "Write to the school", "write", rated(Some(5), None, None, None, None), &[]);
1351        assert_eq!(index.proposal(&letter), Proposal::default());
1352        // Felt for its title, without a kind: from the item alone.
1353        let bare = felt_task("bare", "Call the bank", "", Demands::default(), &[]);
1354        assert_eq!(index.proposal(&bare).from, "item");
1355        assert_eq!(index.proposal(&bare).values.cognitive, None, "no kind, nothing from calls");
1356        assert_eq!(serde_json::to_value(index.proposal(&bare)).unwrap(), serde_json::json!({ "values": { "cognitive": null, "emotional": null, "anxiety": 4, "body": null, "gain": 6 }, "from": "item" }));
1357    }
1358
1359    fn done(uid: &str, estimate: u32, first: Option<u32>, on: &str, area: &str) -> Task {
1360        let completed = day(on).at(17, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp().as_second();
1361        Task { uid: uid.into(), title: uid.into(), estimate, estimate_first: first, status: Status::Completed, completed: Some(completed), area: area.into(), ..Task::default() }
1362    }
1363
1364    fn session(uid: &str, on: &str, minutes: u32, kind: Option<Kind>) -> Session {
1365        Session { task: uid.into(), start: day(on).at(9, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp().as_second(), minutes, kind, ..Session::default() }
1366    }
1367
1368    fn area(task: &Task) -> &'static str {
1369        match task.area.as_str() {
1370            "work" => "work",
1371            "admin" => "admin",
1372            _ => "leisure",
1373        }
1374    }
1375
1376    #[test]
1377    fn the_ratio_learns_from_finished_timed_tasks() {
1378        let today = day("2026-10-06");
1379        // Twelve admin tasks, each guessed 30 min (first), timed at 45: ratio 1.5.
1380        let mut tasks = Vec::new();
1381        let mut sessions = Vec::new();
1382        for i in 0..12 {
1383            let on = format!("2026-10-{:02}", 1 + i % 5);
1384            tasks.push(done(&format!("a{i}"), 60, Some(30), &on, "admin"));
1385            sessions.push(session(&format!("a{i}"), &on, 45, Some(Kind::Measured)));
1386        }
1387        let all = finished(&tasks, &sessions, &area, &TimeZone::UTC);
1388        assert_eq!(all.len(), 12);
1389        assert!((all[0].log - 1.5f32.ln()).abs() < 1e-5, "the first estimate, not the current one: {}", all[0].log);
1390        let r = ratios(&all, today);
1391        assert!((r.person.exp() - 1.5).abs() < 0.01, "stands alone past nine tasks: {}", r.person.exp());
1392        assert!((r.factor("admin") - 1.5).abs() < 0.02, "{}", r.factor("admin"));
1393        assert_eq!(r.corrected(40, "admin"), Some(60));
1394        assert_eq!(r.corrected(0, "admin"), None, "no estimate of its own: never invented");
1395        // A kind with no task: yours.
1396        assert!((r.factor("work") - r.person.exp()).abs() < 1e-6);
1397        // Three tasks only: a third of the way from 1.1× to theirs.
1398        let three = ratios(&all[..3], today);
1399        let expected = (1.0 / 3.0) * 1.5f32.ln() + (2.0 / 3.0) * 1.1f32.ln();
1400        assert!((three.person - expected).abs() < 1e-4, "{} vs {expected}", three.person);
1401        // A kind pulled toward yours: four work tasks at 2×, among admin at 1.5×.
1402        let mut more = tasks.clone();
1403        let mut more_sessions = sessions.clone();
1404        for i in 0..4 {
1405            more.push(done(&format!("w{i}"), 30, Some(30), "2026-10-05", "work"));
1406            more_sessions.push(session(&format!("w{i}"), "2026-10-05", 60, Some(Kind::Measured)));
1407        }
1408        let r = ratios(&finished(&more, &more_sessions, &area, &TimeZone::UTC), today);
1409        let work = r.factor("work");
1410        assert!(work < 2.0 && work > r.person.exp(), "work between yours and its own: {work}");
1411    }
1412
1413    #[test]
1414    fn typed_time_counts_little_and_open_or_dropped_tasks_not_at_all() {
1415        let today = day("2026-10-06");
1416        let mut tasks = vec![done("typed", 30, Some(30), "2026-10-05", "admin"), done("timed", 30, Some(30), "2026-10-05", "admin")];
1417        let mut open = done("open", 30, Some(30), "2026-10-05", "admin");
1418        open.status = Status::NeedsAction;
1419        let mut dropped = done("dropped", 30, Some(30), "2026-10-05", "admin");
1420        dropped.status = Status::Cancelled;
1421        tasks.extend([open, dropped]);
1422        let sessions = vec![session("typed", "2026-10-05", 90, Some(Kind::Typed)), session("timed", "2026-10-05", 30, Some(Kind::Corrected)), session("open", "2026-10-05", 300, Some(Kind::Measured)), session("dropped", "2026-10-05", 300, Some(Kind::Measured)), session("typed", "2026-10-05", 0, None)];
1423        let all = finished(&tasks, &sessions, &area, &TimeZone::UTC);
1424        assert_eq!(all.iter().map(|f| f.uid.as_str()).collect::<Vec<_>>(), vec!["typed", "timed"], "only finished tasks");
1425        assert_eq!((all[0].weight, all[1].weight), (TYPED_WEIGHT, 1.0));
1426        let r = ratios(&all, today);
1427        // Weighted: the typed 3× counts a quarter as much as the timed 1×.
1428        let mean = (0.25 * 3f32.ln() + 1.0 * 0.0) / 1.25;
1429        let w = 1.25 / STANDS_FROM;
1430        assert!((r.person - (w * mean + (1.0 - w) * 1.1f32.ln())).abs() < 1e-4, "{}", r.person);
1431        // An old session without a kind counts as typed.
1432        let unknown = finished(&[done("u", 30, None, "2026-10-05", "admin")], &[session("u", "2026-10-05", 60, None)], &area, &TimeZone::UTC);
1433        assert_eq!(unknown[0].weight, TYPED_WEIGHT);
1434    }
1435
1436    #[test]
1437    fn recent_tasks_weigh_more() {
1438        let today = day("2026-10-20");
1439        // Twenty tasks a month ago at 2×, ten yesterday at 1×.
1440        let mut tasks = Vec::new();
1441        let mut sessions = Vec::new();
1442        for i in 0..20 {
1443            tasks.push(done(&format!("o{i}"), 30, Some(30), "2026-09-20", "admin"));
1444            sessions.push(session(&format!("o{i}"), "2026-09-20", 60, Some(Kind::Measured)));
1445        }
1446        for i in 0..10 {
1447            tasks.push(done(&format!("n{i}"), 30, Some(30), "2026-10-19", "admin"));
1448            sessions.push(session(&format!("n{i}"), "2026-10-19", 30, Some(Kind::Measured)));
1449        }
1450        let r = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1451        assert!(r.person.exp() < 1.05, "a month old weighs almost nothing at a half-life of 4.5 days: {}", r.person.exp());
1452    }
1453
1454    #[test]
1455    fn today_is_weighed_by_its_finished_tasks() {
1456        let today = day("2026-10-06");
1457        let mut tasks = Vec::new();
1458        let mut sessions = Vec::new();
1459        for i in 0..10 {
1460            tasks.push(done(&format!("p{i}"), 30, Some(30), "2026-10-02", "admin"));
1461            sessions.push(session(&format!("p{i}"), "2026-10-02", 30, Some(Kind::Measured)));
1462        }
1463        let calm = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1464        assert_eq!((calm.today, calm.today_factor()), (0.0, 1.0));
1465        // Two done today, each twice as long as usual: the rest of today runs longer, shrunk toward ordinary.
1466        for i in 0..2 {
1467            tasks.push(done(&format!("t{i}"), 30, Some(30), "2026-10-06", "admin"));
1468            sessions.push(session(&format!("t{i}"), "2026-10-06", 60, Some(Kind::Measured)));
1469        }
1470        let slow = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1471        assert!(slow.today > 0.0 && slow.today_factor() < 2.0 && slow.today_sigma < Spread::of(slow.sigma).day, "{slow:?}");
1472    }
1473
1474    #[test]
1475    fn slack_at_p85_is_deterministic() {
1476        let spread = Spread::of(PRIOR_SIGMA);
1477        let medians = [30, 30, 30, 30, 30];
1478        let a = slack(&medians, spread, 0.0, seed(day("2026-10-06"), &["a", "b"]));
1479        let b = slack(&medians, spread, 0.0, seed(day("2026-10-06"), &["a", "b"]));
1480        assert_eq!(a, b, "the same day, the same steps: the same slack");
1481        // TE's own figures: five 30-min steps, σ 0.5, ρ 0.3: P80 ≈ 255 against 150 + medians' sum.
1482        assert!((30..=110).contains(&a), "{a}");
1483        // A wider day, more slack; nothing to do, none.
1484        assert!(slack(&medians, Spread::of(0.8), 0.0, 1) > slack(&medians, Spread::of(0.3), 0.0, 1));
1485        assert_eq!(slack(&[], spread, 0.0, 1), 0);
1486        // A day going slow keeps more.
1487        assert!(slack(&medians, spread, 0.3, 7) > slack(&medians, spread, 0.0, 7));
1488        assert_ne!(seed(day("2026-10-06"), &["a"]), seed(day("2026-10-07"), &["a"]));
1489    }
1490
1491    #[test]
1492    fn the_estimate_follows_the_simulation() {
1493        // Fenton–Wilkinson against 2000 draws: within a tenth, or three minutes, on days of every shape.
1494        for (medians, sigma, effect) in [(vec![30u32, 30, 30, 30, 30], 0.5f32, 0.0f32), (vec![240, 17], 0.707, 0.0), (vec![15, 15, 15], 0.707, 0.0), (vec![90, 45, 20], 0.3, 0.2), (vec![180], 0.5, 0.0)] {
1495            let spread = Spread::of(sigma);
1496            let sum: f32 = medians.iter().map(|&m| m as f32).sum();
1497            let squares: f32 = medians.iter().map(|&m| (m * m) as f32).sum();
1498            let simulated = slack(&medians, spread, effect, 42) as f32;
1499            let estimated = slack_estimate(sum, squares, spread, effect);
1500            assert!((estimated - simulated).abs() <= (0.1 * simulated).max(3.0), "{medians:?} σ {sigma}: {estimated} against {simulated}");
1501        }
1502        assert_eq!(slack_estimate(0.0, 0.0, Spread::of(0.5), 0.0), 0.0);
1503    }
1504
1505    /// A record of `n` days from `from`, each `load` (every cost and the total), with `outcome`.
1506    fn days(from: &str, n: i64, load: f32, outcome: Option<Outcome>) -> Vec<DayRecord> {
1507        (0..n)
1508            .map(|i| {
1509                let date = day(from).checked_add(jiff::Span::new().days(i)).unwrap();
1510                DayRecord { date, load: [load; 5], known: [true; 5], good_hours: 1.0, gain_known: true, outcome }
1511            })
1512            .collect()
1513    }
1514
1515    fn after(records: &[DayRecord]) -> Date {
1516        records.last().unwrap().date.tomorrow().unwrap()
1517    }
1518
1519    const START: Load = [10.0; 5];
1520
1521    #[test]
1522    fn rises_only_after_seven_stable_full_days() {
1523        // Six good days at 90 %: no rise yet.
1524        let six = days("2026-09-01", 6, 9.0, Some(Outcome::AboutRight));
1525        assert_eq!(learn(&six, START, 28, after(&six)).budget[0], 10.0);
1526        // Seven: a rise, toward what they showed, a tenth at most; the start still weighs (α = 7/14).
1527        let seven = days("2026-09-01", 7, 12.0, Some(Outcome::AboutRight));
1528        let learned = learn(&seven, START, 28, after(&seven));
1529        assert!((learned.budget[0] - 11.0).abs() < 1e-4, "{:?}", learned.budget);
1530        // Days at 75 % on average: informative, but not stable at 80 %: none.
1531        let mild = days("2026-09-01", 10, 7.5, Some(Outcome::AboutRight));
1532        assert_eq!(learn(&mild, START, 28, after(&mild)).budget[0], 10.0);
1533        // Light good days say only "at least this much": never a fall.
1534        let light = days("2026-09-01", 20, 3.0, Some(Outcome::AboutRight));
1535        assert_eq!(learn(&light, START, 28, after(&light)).budget, START);
1536        // A week after the rise, another tenth at most: never on a timetable, always after stable days.
1537        let fourteen = days("2026-09-01", 14, 14.0, Some(Outcome::AboutRight));
1538        let learned = learn(&fourteen, START, 28, after(&fourteen));
1539        assert!((learned.budget[0] - 12.1).abs() < 1e-3, "two rises in two weeks: {:?}", learned.budget);
1540    }
1541
1542    #[test]
1543    fn falls_a_fifth_after_a_bad_day_following_a_full_one() {
1544        let mut record = days("2026-09-01", 3, 5.0, None);
1545        record[1].load = [9.0; 5]; // 90 %
1546        record[2].outcome = Some(Outcome::TooMuch);
1547        let learned = learn(&record, START, 28, after(&record));
1548        assert!((learned.budget[0] - 8.0).abs() < 1e-5, "{:?}", learned.budget);
1549        assert_eq!(learned.changes.len(), 5);
1550        // A bad day after a light day: nothing learned downward.
1551        let mut light = days("2026-09-01", 3, 5.0, None);
1552        light[2].outcome = Some(Outcome::TooMuch);
1553        assert_eq!(learn(&light, START, 28, after(&light)).budget, START);
1554        // Two bad days after one full day: blamed once.
1555        let mut twice = days("2026-09-01", 4, 5.0, None);
1556        twice[1].load = [9.0; 5];
1557        twice[2].outcome = Some(Outcome::TooMuch);
1558        twice[3].outcome = Some(Outcome::TooMuch);
1559        assert!((learn(&twice, START, 28, after(&twice)).budget[0] - 8.0).abs() < 1e-5);
1560        // Only the cost that was full falls.
1561        let mut one = days("2026-09-01", 3, 5.0, None);
1562        one[1].load = [2.0, 9.0, 2.0, 2.0, 9.0];
1563        one[2].outcome = Some(Outcome::TooMuch);
1564        let learned = learn(&one, START, 28, after(&one));
1565        assert_eq!((learned.budget[0], learned.budget[1]), (10.0, 8.0));
1566        // Never below a quarter of the start.
1567        let mut crash = Vec::new();
1568        for k in 0..20 {
1569            let mut pair = days(&day("2026-09-01").checked_add(jiff::Span::new().days(2 * k)).unwrap().to_string(), 2, 100.0, None);
1570            pair[1].outcome = Some(Outcome::TooMuch);
1571            crash.extend(pair);
1572        }
1573        assert!((learn(&crash, START, 90, after(&crash)).budget[0] - 2.5).abs() < 1e-5);
1574    }
1575
1576    #[test]
1577    fn freezes_after_three_bad_days_in_seven() {
1578        // A full week, then a flare: three bad days; then good full days.
1579        let mut record = days("2026-09-01", 7, 9.0, Some(Outcome::AboutRight));
1580        for date in ["2026-09-08", "2026-09-10", "2026-09-12"] {
1581            record.push(DayRecord { outcome: Some(Outcome::TooMuch), ..days(date, 1, 2.0, None).remove(0) });
1582        }
1583        record.extend(days("2026-09-13", 6, 12.0, Some(Outcome::AboutRight)));
1584        let learned = learn(&record, START, 28, after(&record));
1585        assert!(learned.frozen, "six good days after a flare: still held");
1586        let before = learned.budget[0];
1587        // A seventh good day: the freeze lifts, rises may come back after a stable week.
1588        record.extend(days("2026-09-19", 1, 12.0, Some(Outcome::AboutRight)));
1589        let learned = learn(&record, START, 28, after(&record));
1590        assert!(!learned.frozen);
1591        assert!(learned.budget[0] >= before);
1592    }
1593
1594    #[test]
1595    fn the_cap_and_the_window() {
1596        // Three fine days in the last thirty, the heaviest at 6: a day planned at 6.6 at most.
1597        let mut record = days("2026-09-20", 3, 4.0, Some(Outcome::AboutRight));
1598        record[1].load = [6.0; 5];
1599        let learned = learn(&record, START, 28, day("2026-09-25"));
1600        assert!((learned.cap[0].unwrap() - 6.6).abs() < 1e-4);
1601        assert!((learned.limit()[0] - 6.6).abs() < 1e-4, "the budget under its cap");
1602        // Two only: not known yet.
1603        assert_eq!(learn(&record[..2], START, 28, day("2026-09-25")).cap[0], None);
1604        // Forty days later, out of the last thirty: gone.
1605        assert_eq!(learn(&record, START, 28, day("2026-11-05")).cap[0], None);
1606        // The window: a rise needs its stable week inside it; days with no outcome weigh nothing.
1607        let mut sparse = days("2026-09-01", 7, 12.0, Some(Outcome::AboutRight));
1608        sparse.extend(days("2026-09-08", 20, 2.0, None));
1609        let learned = learn(&sparse, START, 28, after(&sparse));
1610        assert_eq!((learned.days, learned.budget[0] > 10.0), (7, true), "missing days count for nothing");
1611        let short = learn(&sparse, START, 14, day("2026-10-20"));
1612        assert_eq!(short.days, 0, "out of a 14-day window");
1613    }
1614
1615    #[test]
1616    fn learned_weight_grows_to_fourteen_days() {
1617        // Ten days at 150 %: the target is pulled toward the start (α = 10/14).
1618        let record = days("2026-09-01", 10, 15.0, Some(Outcome::AboutRight));
1619        let learned = learn(&record, START, 28, after(&record));
1620        // Rise on day 7 (α 0.5 → target 12.5, a tenth: 11).
1621        assert!((learned.budget[0] - 11.0).abs() < 1e-4, "{:?}", learned.budget);
1622    }
1623
1624    #[test]
1625    fn the_gain_minimum() {
1626        let mut record = days("2026-09-01", 4, 1.0, Some(Outcome::AboutRight));
1627        // Fewer than five good days: the default, one good hour.
1628        assert_eq!(learn(&record, START, 28, after(&record)).minimum, GAIN_DEFAULT);
1629        // Good days with 2, 2.5, 3, 3.5, 4, 4.5 good hours: the 25th percentile, weighted toward recent days.
1630        record = days("2026-09-01", 6, 1.0, Some(Outcome::AboutRight));
1631        for (k, r) in record.iter_mut().enumerate() {
1632            r.good_hours = 2.0 + k as f32 * 0.5;
1633        }
1634        let learned = learn(&record, START, 28, after(&record));
1635        assert!(learned.minimum >= 2.5 && learned.minimum <= 3.5, "{}", learned.minimum);
1636        // Bounded: never above 4 good hours, never below half of one.
1637        let mut high = days("2026-09-01", 8, 1.0, Some(Outcome::AboutRight));
1638        high.iter_mut().for_each(|r| r.good_hours = 9.0);
1639        assert_eq!(learn(&high, START, 28, after(&high)).minimum, GAIN_HIGHEST);
1640        let mut low = days("2026-09-01", 8, 1.0, Some(Outcome::AboutRight));
1641        low.iter_mut().for_each(|r| r.good_hours = 0.1);
1642        assert_eq!(learn(&low, START, 28, after(&low)).minimum, GAIN_LOWEST);
1643        // A bad stretch never teaches a lower minimum: three too-much days, then good days with little.
1644        let mut stretch = days("2026-09-01", 6, 1.0, Some(Outcome::AboutRight));
1645        stretch.iter_mut().for_each(|r| r.good_hours = 3.0);
1646        let kept = learn(&stretch, START, 28, after(&stretch)).minimum;
1647        stretch.extend(days("2026-09-07", 3, 1.0, Some(Outcome::TooMuch)));
1648        let mut thin = days("2026-09-10", 3, 1.0, Some(Outcome::AboutRight));
1649        thin.iter_mut().for_each(|r| r.good_hours = 0.2);
1650        stretch.extend(thin);
1651        assert_eq!(learn(&stretch, START, 28, after(&stretch)).minimum, kept);
1652    }
1653
1654    #[test]
1655    fn the_start_is_as_now_by_default() {
1656        // Eight hours of room: a full day of usual work and two heavy hours fit under the fill.
1657        let budget = start_budget(480, Start::AsNow);
1658        let full_day = 8.0 * 4.0 + 2.0 * 3.0;
1659        assert!((budget[TOTAL] * FILL - full_day).abs() < 1e-3);
1660        assert!((start_budget(480, Start::MuchLighter)[TOTAL] - budget[TOTAL] / 2.0).abs() < 1e-3);
1661        assert_eq!((Start::parse("lighter"), Start::parse("?")), (Start::Lighter, Start::AsNow));
1662    }
1663
1664    #[test]
1665    fn day_records_from_sessions_completions_and_events() {
1666        let zone = TimeZone::UTC;
1667        let on = day("2026-10-05");
1668        let at = |h: i8| on.at(h, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
1669        let mut report = Task { uid: "report".into(), title: "Report".into(), demands: rated(Some(6), None, None, None, Some(4)), ..Task::default() };
1670        report.felt = vec![Felt { on: Some(on), demands: rated(Some(8), None, None, None, None) }];
1671        let quick = Task { uid: "quick".into(), title: "Quick".into(), estimate: 20, status: Status::Completed, completed: Some(at(15)), energy: "light".into(), ..Task::default() };
1672        let sessions = vec![Session { task: "report".into(), start: at(9), minutes: 90, kind: Some(Kind::Measured), ..Session::default() }, Session { project: "lumen".into(), start: at(11), minutes: 60, ..Session::default() }];
1673        let dentist = Occurrence { start: at(16), end: at(17), demands: rated(None, None, Some(7), Some(5), None), ..Occurrence::default() };
1674        let lunch = Occurrence { start: at(12), end: at(13), all_day: true, ..Occurrence::default() };
1675        let outcomes = BTreeMap::from([(on, Outcome::TooMuch)]);
1676        let tasks = vec![report, quick];
1677        let index = FeltIndex::of(&tasks);
1678        let records = day_records(&tasks, &sessions, &[dentist, lunch], &index, &|t| t.estimate.max(30), &outcomes, on, on, &zone);
1679        assert_eq!(records.len(), 1);
1680        let r = &records[0];
1681        // Report: felt that day 8 × 1.5 h (its forecast gain 4 kept); project time: usual 4 × 1 h; quick: light 2 × 0.5 h (its 20 min count 30); dentist: 7 × 1 h.
1682        assert_eq!(r.load[0], 12.0);
1683        assert_eq!(r.load[2], 7.0);
1684        assert_eq!(r.load[3], 5.0);
1685        assert_eq!(r.load[TOTAL], 12.0 + 4.0 + 1.0 + 7.0);
1686        assert_eq!(r.known, [true, false, true, true, true]);
1687        assert!((r.good_hours - 0.4 * 1.5).abs() < 1e-5 && r.gain_known);
1688        assert_eq!(r.outcome, Some(Outcome::TooMuch));
1689    }
1690
1691    #[test]
1692    fn a_balance_in_words_only() {
1693        let tr = Translator::new("en");
1694        let learned = Learned { budget: [10.0; 5], start: [10.0; 5], cap: [None; 5], frozen: false, minimum: 1.0, days: 10, changes: Vec::new() };
1695        let record = DayRecord { date: day("2026-10-05"), load: [2.0, 12.0, 6.0, 0.0, 12.0], known: [true, true, true, false, true], good_hours: 0.2, gain_known: true, outcome: Some(Outcome::AboutRight) };
1696        let balance = balance(&record, &learned, None, &tr);
1697        let levels: Vec<(&str, &str)> = balance.costs.iter().map(|c| (c.scale, c.level)).collect();
1698        assert_eq!(levels, vec![("cognitive", "light"), ("emotional", "heavy"), ("anxiety", "usual"), ("body", ""), ("total", "heavy")]);
1699        assert_eq!(balance.gain.level, "below");
1700        let unknown = DayRecord { gain_known: false, ..record.clone() };
1701        assert_eq!(super::balance(&unknown, &learned, None, &tr).gain.level, "", "nothing carried a gain: not known");
1702        // A day said too much is never called light.
1703        let bad = DayRecord { outcome: Some(Outcome::TooMuch), ..record };
1704        assert_eq!(super::balance(&bad, &learned, None, &tr).costs[0].level, "usual");
1705        // Tomorrow lowered: said once, in words, no number.
1706        let lower = Learned { budget: [8.0; 5], ..learned.clone() };
1707        let said = super::balance(&bad, &learned, Some(&lower), &tr).said;
1708        assert_eq!(said.len(), 1);
1709        assert!(!said[0].chars().any(|c| c.is_ascii_digit()) && !said[0].contains('%'), "{said:?}");
1710    }
1711
1712    #[test]
1713    fn heavy_events_lighten_the_days_around() {
1714        let zone = TimeZone::UTC;
1715        let at = |d: &str, h: i8| day(d).at(h, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
1716        let court = Occurrence { start: at("2026-10-08", 10), end: at("2026-10-08", 12), demands: rated(None, Some(8), Some(9), None, None), ..Occurrence::default() };
1717        let coffee = Occurrence { start: at("2026-10-12", 10), end: at("2026-10-12", 11), demands: rated(Some(2), None, None, None, Some(7)), ..Occurrence::default() };
1718        let around = around_heavy(&[court, coffee], &zone, day("2026-10-06"));
1719        assert_eq!(around, BTreeSet::from([day("2026-10-07"), day("2026-10-09")]));
1720        let planning = Planning { limit: Some([10.0; 5]), lightened: around, ..Planning::default() };
1721        assert_eq!(planning.day_limit(day("2026-10-07"), 1.0).unwrap()[0], 10.0 * FILL * AROUND_HEAVY);
1722        assert_eq!(planning.day_limit(day("2026-10-08"), 0.6).unwrap()[0], 10.0 * FILL * 0.6);
1723        assert_eq!(Planning::default().day_limit(day("2026-10-08"), 1.0), None);
1724    }
1725
1726    #[test]
1727    fn suggestions_from_your_own_well_rated_items_rotated() {
1728        let walk = felt_task("w", "Walk by the river", "", Demands::default(), &[("2026-10-01", rated(Some(1), None, None, Some(2), Some(8)))]);
1729        let music = felt_task("m", "Play the piano", "", Demands::default(), &[("2026-10-02", rated(Some(2), None, None, None, Some(7)))]);
1730        let party = felt_task("p", "Party", "", Demands::default(), &[("2026-10-03", rated(None, Some(6), None, Some(7), Some(9)))]);
1731        let meh = felt_task("x", "Television", "", Demands::default(), &[("2026-10-03", rated(Some(0), None, None, None, Some(4)))]);
1732        let tasks = vec![walk, music, party, meh];
1733        let index = FeltIndex::of(&tasks);
1734        let first = suggestion(&tasks, &index, day("2026-10-06")).unwrap();
1735        let second = suggestion(&tasks, &index, day("2026-10-07")).unwrap();
1736        assert_ne!(first, second, "rotated by day");
1737        for d in ["2026-10-06", "2026-10-07", "2026-10-08"] {
1738            let s = suggestion(&tasks, &index, day(d)).unwrap();
1739            assert!(s == "Walk by the river" || s == "Play the piano", "only light, well-rated items: {s}");
1740        }
1741        assert_eq!(suggestion(&[], &FeltIndex::default(), day("2026-10-06")), None);
1742    }
1743}