Skip to main content

supplier_leadtime/
supplier_leadtime.rs

1//! Supplier delivery-reliability lead-time distribution (see issue #6).
2//!
3//! "Is a supplier hitting its contracted lead time, and what does its lead-time
4//! *distribution* look like once you exclude delays that weren't its fault?"
5//!
6//! Deliberately **polars-free** — it feeds plain column-oriented `Value` data, so
7//! it builds and runs with `--no-default-features` too. This is the shape a
8//! stateless renderer (peacock) would use, receiving ACL-checked rows as columns.
9//!
10//! Run: `cargo run --example supplier_leadtime`  → writes PNGs to assets/gallery/.
11
12use ggplot_rs::prelude::*;
13
14const W: u32 = 720;
15const H: u32 = 500;
16const CONTRACT: f64 = 30.0; // contracted lead time (SLA), days
17
18// Brand palette (issue #5): DataZoo teal + risk-ish accents.
19const TEAL: (u8, u8, u8) = (26, 153, 136);
20const CONTRACT_RED: (u8, u8, u8) = (200, 60, 60);
21const P90_BLUE: (u8, u8, u8) = (60, 90, 200);
22const MUTED: (u8, u8, u8) = (150, 150, 150);
23
24fn out(name: &str) -> String {
25    format!("assets/gallery/{name}.png")
26}
27
28/// Tiny deterministic PRNG so the example is reproducible without `rand`.
29struct Lcg(u64);
30impl Lcg {
31    fn u01(&mut self) -> f64 {
32        self.0 = self
33            .0
34            .wrapping_mul(6364136223846793005)
35            .wrapping_add(1442695040888963407);
36        ((self.0 >> 32) as u32) as f64 / (u64::from(u32::MAX) as f64 + 1.0)
37    }
38    /// Approx N(0,1) via the central limit theorem (sum of 6 uniforms).
39    fn normalish(&mut self) -> f64 {
40        (0..6).map(|_| self.u01()).sum::<f64>() - 3.0
41    }
42}
43
44struct Supplier {
45    name: &'static str,
46    mean: f64,
47    sd: f64,
48    seed: u64,
49    n: usize,
50}
51
52/// One row per received purchase order.
53struct Po {
54    supplier: &'static str,
55    lead: f64,
56    attributable: bool,
57}
58
59fn simulate() -> Vec<Po> {
60    let suppliers = [
61        Supplier {
62            name: "Meridian Components",
63            mean: 26.0,
64            sd: 5.0,
65            seed: 11,
66            n: 200,
67        },
68        Supplier {
69            name: "Aurora Freight",
70            mean: 34.0,
71            sd: 8.0,
72            seed: 23,
73            n: 200,
74        },
75        Supplier {
76            name: "Boreal Supply",
77            mean: 28.0,
78            sd: 3.0,
79            seed: 37,
80            n: 200,
81        },
82        Supplier {
83            name: "Cobalt Metals",
84            mean: 30.0,
85            sd: 12.0,
86            seed: 51,
87            n: 200,
88        },
89    ];
90
91    let mut rows = Vec::new();
92    for s in suppliers {
93        let mut rng = Lcg(s.seed);
94        for _ in 0..s.n {
95            // ~8% of deliveries are excused (external disruption) — and tend to be
96            // much later, which is exactly why they must be excluded from the SLA view.
97            let excused = rng.u01() < 0.08;
98            let lead = if excused {
99                s.mean + s.sd * rng.normalish() + 20.0 + 15.0 * rng.u01()
100            } else {
101                s.mean + s.sd * rng.normalish()
102            }
103            .max(1.0);
104            rows.push(Po {
105                supplier: s.name,
106                lead,
107                attributable: !excused,
108            });
109        }
110    }
111    rows
112}
113
114fn percentile(mut xs: Vec<f64>, p: f64) -> f64 {
115    xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
116    if xs.is_empty() {
117        return 0.0;
118    }
119    let idx = ((xs.len() - 1) as f64 * p).round() as usize;
120    xs[idx]
121}
122
123fn main() -> Result<(), Box<dyn std::error::Error>> {
124    std::fs::create_dir_all("assets/gallery")?;
125    let rows = simulate();
126
127    detail(&rows)?;
128    ecdf(&rows)?;
129    compare(&rows)?;
130
131    println!("Supplier lead-time charts written to assets/gallery/");
132    Ok(())
133}
134
135/// One supplier: density of *attributable* lead times, contract + p90 markers,
136/// and excused deliveries shown as a muted rug (not folded into the estimate).
137fn detail(rows: &[Po]) -> Result<(), Box<dyn std::error::Error>> {
138    let name = "Meridian Components";
139    let mine: Vec<&Po> = rows.iter().filter(|p| p.supplier == name).collect();
140
141    let attributable: Vec<f64> = mine
142        .iter()
143        .filter(|p| p.attributable)
144        .map(|p| p.lead)
145        .collect();
146    let p90 = percentile(attributable.clone(), 0.90);
147
148    let dens_data: Vec<(String, Vec<Value>)> = vec![(
149        "lead".to_string(),
150        attributable.iter().map(|v| Value::Float(*v)).collect(),
151    )];
152    let excused_data: Vec<(String, Vec<Value>)> = vec![(
153        "lead".to_string(),
154        mine.iter()
155            .filter(|p| !p.attributable)
156            .map(|p| Value::Float(p.lead))
157            .collect(),
158    )];
159
160    GGPlot::new(dens_data)
161        .aes(Aes::new().x("lead"))
162        .geom_density_with(GeomDensity {
163            fill: TEAL,
164            color: (20, 110, 98),
165            alpha: 0.45,
166            line_width: 1.5,
167        })
168        // SLA threshold — mass to the left is the on-time share.
169        .geom_vline_with(GeomVline {
170            xintercept: CONTRACT,
171            color: CONTRACT_RED,
172            width: 1.5,
173            linetype: Linetype::Dashed,
174            alpha: 1.0,
175        })
176        // p90 — the number to size safety stock against.
177        .geom_vline_with(GeomVline {
178            xintercept: p90,
179            color: P90_BLUE,
180            width: 1.5,
181            linetype: Linetype::Dashed,
182            alpha: 1.0,
183        })
184        // Excused deliveries, shown distinctly and kept out of the density.
185        .geom_rug_with(GeomRug {
186            color: MUTED,
187            alpha: 0.7,
188            length: 0.04,
189            sides: "b".to_string(),
190        })
191        .layer_data(excused_data)
192        .layer_aes(Aes::new().x("lead"))
193        .annotate_text(&format!("contract {CONTRACT:.0}d"), CONTRACT + 1.0, 0.075)
194        .annotate_text(&format!("p90 {p90:.0}d"), p90 + 1.0, 0.06)
195        .annotate_text("<- excused (external)", 48.0, 0.008)
196        .title(&format!("{name} — lead-time distribution"))
197        .subtitle("attributable deliveries only; excused shown as rug")
198        .xlab("Actual lead time (days)")
199        .ylab("Density")
200        .theme_minimal()
201        .save_with_size(&out("supplier_leadtime"), W, H)?;
202    Ok(())
203}
204
205/// ECDF for one supplier — P(lead <= contract) reads straight off the curve.
206fn ecdf(rows: &[Po]) -> Result<(), Box<dyn std::error::Error>> {
207    let name = "Meridian Components";
208    let attributable: Vec<f64> = rows
209        .iter()
210        .filter(|p| p.supplier == name && p.attributable)
211        .map(|p| p.lead)
212        .collect();
213    let on_time =
214        attributable.iter().filter(|&&l| l <= CONTRACT).count() as f64 / attributable.len() as f64;
215    let data: Vec<(String, Vec<Value>)> = vec![(
216        "lead".to_string(),
217        attributable.iter().map(|v| Value::Float(*v)).collect(),
218    )];
219
220    GGPlot::new(data)
221        .aes(Aes::new().x("lead"))
222        .geom_step()
223        .stat(StatEcdf)
224        .geom_vline_with(GeomVline {
225            xintercept: CONTRACT,
226            color: CONTRACT_RED,
227            width: 1.5,
228            linetype: Linetype::Dashed,
229            alpha: 1.0,
230        })
231        .annotate_text(
232            &format!("on-time rate {:.0}%", on_time * 100.0),
233            CONTRACT + 1.0,
234            0.15,
235        )
236        .title(&format!("{name} — on-time reliability (ECDF)"))
237        .subtitle("cumulative share at the contract line = P(lead <= 30d)")
238        .xlab("Actual lead time (days)")
239        .ylab("Cumulative share")
240        .theme_bw()
241        .save_with_size(&out("supplier_leadtime_ecdf"), W, H)?;
242    Ok(())
243}
244
245/// Compare suppliers: overlaid per-supplier densities against the contract line.
246fn compare(rows: &[Po]) -> Result<(), Box<dyn std::error::Error>> {
247    let lead: Vec<Value> = rows
248        .iter()
249        .filter(|p| p.attributable)
250        .map(|p| Value::Float(p.lead))
251        .collect();
252    let supplier: Vec<Value> = rows
253        .iter()
254        .filter(|p| p.attributable)
255        .map(|p| Value::Str(p.supplier.to_string()))
256        .collect();
257    let data: Vec<(String, Vec<Value>)> = vec![
258        ("lead".to_string(), lead),
259        ("supplier".to_string(), supplier),
260    ];
261
262    GGPlot::new(data)
263        .aes(Aes::new().x("lead").fill("supplier").color("supplier"))
264        .geom_density_with(GeomDensity {
265            alpha: 0.35,
266            line_width: 1.2,
267            ..Default::default()
268        })
269        .geom_vline_with(GeomVline {
270            xintercept: CONTRACT,
271            color: CONTRACT_RED,
272            width: 1.2,
273            linetype: Linetype::Dashed,
274            alpha: 1.0,
275        })
276        .scale_fill_brewer(PaletteName::Dark2)
277        .scale_color_brewer(PaletteName::Dark2)
278        .annotate_text("contract", CONTRACT + 1.0, 0.005)
279        .title("Lead-time distributions by supplier")
280        .subtitle("attributable deliveries; dashed line = contracted lead time")
281        .xlab("Actual lead time (days)")
282        .ylab("Density")
283        .theme_minimal()
284        .save_with_size(&out("supplier_leadtime_compare"), W, H)?;
285    Ok(())
286}