Skip to main content

kallo_cli/
stockfile.rs

1//! A stock as data: the file format, its parser, and its checker.
2//!
3//! ADR 0012 in the private folder argues that a stock should be a file rather
4//! than a `&'static` struct in `emulsion.rs`, because the cost of the *n*th
5//! stock is otherwise an edit to the model. This module is the format and the
6//! gate: `kalo stock check` reads one, refuses one that claims to be measured
7//! without naming its document, and re-derives the curve from the digitised
8//! points to catch a transcription slip.
9//!
10//! ## The format
11//!
12//! TOML, in the subset this parser accepts: `[section]` and `[section.key]`
13//! headers, `key = value` pairs, `#` comments, double-quoted strings with no
14//! escapes, numbers, and single-line arrays of either. That is enough for every
15//! field a stock has, and it keeps the file readable and hand-editable without
16//! pulling a serialiser into a crate that has one dependency.
17//!
18//! ```toml
19//! [stock]
20//! id = "portra_endura"
21//! kind = "paper"            # or "film"
22//! claim = "measured"        # or "placeholder"
23//!
24//! [provenance]
25//! publication = "E-4021"
26//! page = 7
27//! retrieved = "2026-11-07"
28//! checksum = "sha256:04691bc2..."
29//!
30//! [curve.red]
31//! d_min = 0.0909
32//! d_max = 2.6087
33//! gamma = 3.68
34//! log_e0 = -1.4125
35//! transition = 7.0
36//! points = "data/sensitometry/portra_endura_red.csv"
37//! rms = 0.017
38//! ```
39//!
40//! ## What the checker enforces
41//!
42//! 1. A stock that says `claim = "measured"` must name the document, the page,
43//!    the retrieval date and the checksum of the file it came from. Without them
44//!    it is a placeholder, whatever it says.
45//! 2. The curve has to be a curve: `d_min < d_max`, positive `gamma`, and a
46//!    transition that is not zero.
47//! 3. If the stock names the digitised points, **the numbers are re-derived from
48//!    them** and the fit's rms must be what the file claims. A transposed digit
49//!    fails here instead of rendering plausibly.
50//! 4. If it names spectra, each must be a 71-sample grid from 380 to 730 nm at
51//!    5 nm — the grid the model integrates on — with finite, non-negative values.
52
53use std::collections::BTreeMap;
54use std::fmt;
55use std::path::{Path, PathBuf};
56
57use kallo_core::CharacteristicCurve;
58
59/// What a stock file said, and where it said it.
60#[derive(Debug, Clone)]
61pub struct StockFile {
62    /// The path it was read from, for messages.
63    pub path: PathBuf,
64    /// The parsed keys, section-qualified.
65    table: BTreeMap<String, Value>,
66}
67
68impl StockFile {
69    /// Read and parse a stock file.
70    ///
71    /// # Errors
72    ///
73    /// Fails on a read error, a syntax error, or a missing required field.
74    pub fn read(path: impl AsRef<Path>) -> Result<Self, StockError> {
75        let path = path.as_ref().to_path_buf();
76        let text = std::fs::read_to_string(&path).map_err(|source| StockError::Io {
77            path: path.clone(),
78            source,
79        })?;
80        let table = parse(&text, &path)?;
81        let file = Self { path, table };
82        file.require(&["stock.id", "stock.label", "stock.kind", "stock.claim"])?;
83        let kind = file.string("stock.kind")?;
84        if kind != "film" && kind != "paper" {
85            return Err(file.fail(format!(
86                "stock.kind is {kind:?}; a stock is \"film\" or \"paper\""
87            )));
88        }
89        let claim = file.string("stock.claim")?;
90        if claim != "measured" && claim != "placeholder" {
91            return Err(file.fail(format!(
92                "stock.claim is {claim:?}; it is \"measured\" or \"placeholder\""
93            )));
94        }
95        Ok(file)
96    }
97
98    /// Where the file came from.
99    #[must_use]
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    /// The stock's id.
105    ///
106    /// # Errors
107    ///
108    /// Cannot fail: `read` required it.
109    pub fn id(&self) -> String {
110        self.string("stock.id").unwrap_or_default()
111    }
112
113    /// The stock's human-readable name.
114    ///
115    /// # Errors
116    ///
117    /// Cannot fail: `read` required it.
118    pub fn label(&self) -> String {
119        self.string("stock.label").unwrap_or_default()
120    }
121
122    /// Whether this is a film or a paper.
123    ///
124    /// # Errors
125    ///
126    /// Cannot fail: `read` required it.
127    pub fn is_film(&self) -> bool {
128        self.string("stock.kind")
129            .map(|k| k == "film")
130            .unwrap_or(false)
131    }
132
133    /// Whether this is a reversal film, whose curve falls with exposure.
134    ///
135    /// # Errors
136    ///
137    /// Cannot fail: an absent `stock.process` is a negative.
138    pub fn is_reversal(&self) -> bool {
139        self.string("stock.process")
140            .map(|p| p == "reversal")
141            .unwrap_or(false)
142    }
143
144    /// Whether the file claims the numbers were measured.
145    #[must_use]
146    pub fn claims_measured(&self) -> bool {
147        self.string("stock.claim")
148            .map(|c| c == "measured")
149            .unwrap_or(false)
150    }
151
152    /// Run every check and collect what they found.
153    #[must_use]
154    pub fn check(&self) -> Vec<Finding> {
155        let mut findings = Vec::new();
156        findings.push(self.check_provenance());
157        findings.push(self.check_process());
158        findings.push(self.check_borrowed());
159        findings.extend(self.check_curves());
160        findings.push(self.check_base());
161        findings.push(self.check_emulsion());
162        findings.extend(self.check_spectra());
163        findings.push(self.check_cl_layer());
164        findings
165    }
166
167    /// A stock that claims to be measured must say where its numbers came from.
168    fn check_provenance(&self) -> Finding {
169        if !self.claims_measured() {
170            return Finding::note(
171                "provenance",
172                "placeholder: no document is claimed, and the render says so",
173            );
174        }
175        let mut missing = Vec::new();
176        for key in [
177            "provenance.source",
178            "provenance.publication",
179            "provenance.retrieved",
180            "provenance.checksum",
181        ] {
182            if self.string(key).is_err() {
183                missing.push(key);
184            }
185        }
186        if missing.is_empty() {
187            Finding::ok(
188                "provenance",
189                format!(
190                    "{} p{} retrieved {}, {}",
191                    self.string("provenance.publication").unwrap_or_default(),
192                    self.integer("provenance.page").unwrap_or(0),
193                    self.string("provenance.retrieved").unwrap_or_default(),
194                    self.string("provenance.checksum").unwrap_or_default()
195                ),
196            )
197        } else {
198            Finding::fail(
199                "provenance",
200                format!(
201                    "claims to be measured without {} — it is a placeholder until they are filled in",
202                    missing.join(", ")
203                ),
204            )
205        }
206    }
207
208    /// The three layers, and the fit against the points when they are named.
209    fn check_curves(&self) -> Vec<Finding> {
210        let mut findings = Vec::new();
211        let base = if self.is_film() {
212            self.floats("base.density").ok().filter(|v| v.len() == 3)
213        } else {
214            None
215        };
216        for (index, layer) in ["red", "green", "blue"].into_iter().enumerate() {
217            let prefix = format!("curve.{layer}");
218            let curve = match self.curve(&prefix) {
219                Ok(curve) => curve,
220                Err(error) => {
221                    findings.push(Finding::fail(layer, error.to_string()));
222                    continue;
223                }
224            };
225            // A negative's density rises with exposure and a reversal's falls,
226            // so which end is which depends on the process — the one place the
227            // curve's two numbers are not interchangeable.
228            if self.is_reversal() {
229                if curve.d_min <= curve.d_max {
230                    findings.push(Finding::fail(
231                        layer,
232                        format!(
233                            "d_min {} is not above d_max {} — a reversal starts at its \
234                             maximum density on unexposed film and clears as the image forms",
235                            curve.d_min, curve.d_max
236                        ),
237                    ));
238                    continue;
239                }
240            } else if curve.d_min >= curve.d_max {
241                findings.push(Finding::fail(
242                    layer,
243                    format!("d_min {} is not below d_max {}", curve.d_min, curve.d_max),
244                ));
245                continue;
246            }
247            if curve.gamma <= 0.0 {
248                findings.push(Finding::fail(
249                    layer,
250                    format!("gamma {} is not positive", curve.gamma),
251                ));
252                continue;
253            }
254            // The renderer adds `base.density` to the curve, so a film's curve
255            // is dye above base and its d_min is zero. A file that puts the base
256            // in both places would render the film twice as dense as it is.
257            if self.is_film() && !self.is_reversal() && curve.d_min.abs() > 1e-9 {
258                findings.push(Finding::fail(
259                    layer,
260                    format!(
261                        "d_min {} — a film's curve is dye above base, so d_min is 0 and \
262                         `base.density` carries the rest",
263                        curve.d_min
264                    ),
265                ));
266                continue;
267            }
268            // A reversal's curve is total density, base included, so there is
269            // nothing to add back before comparing it with the sheet's points.
270            let base_for_fit = if self.is_reversal() {
271                None
272            } else {
273                base.as_ref().map(|b| b[index])
274            };
275            match self.check_fit(&prefix, &curve, base_for_fit) {
276                Ok(text) => findings.push(Finding::ok(layer, text)),
277                Err(error) => findings.push(Finding::fail(layer, error.to_string())),
278            }
279        }
280        findings
281    }
282
283    /// Re-derive the fit from the named points and compare with the file's rms.
284    ///
285    /// `base` is this layer's base density when the stock is a film. The model's
286    /// film curve is dye above base, but a data sheet plots total density, so
287    /// the base is added back before the comparison — which is also what makes
288    /// `base.density` a checked number rather than a comment.
289    fn check_fit(
290        &self,
291        prefix: &str,
292        curve: &CharacteristicCurve,
293        base: Option<f64>,
294    ) -> Result<String, StockError> {
295        let Ok(points) = self.string(&format!("{prefix}.points")) else {
296            return Ok("no digitised points named; the curve is taken on trust".to_owned());
297        };
298        let path = self.path.parent().unwrap_or(Path::new(".")).join(&points);
299        let rows = read_points(&path)?;
300        if rows.len() < 20 {
301            return Err(StockError::Check {
302                path: self.path.clone(),
303                stage: prefix.to_owned(),
304                message: format!(
305                    "{} has {} points, which is not a digitised curve",
306                    path.display(),
307                    rows.len()
308                ),
309            });
310        }
311        let scale = self
312            .string(&format!("{prefix}.x_scale"))
313            .unwrap_or_else(|_| "log_exposure".to_owned());
314        let factor = if scale == "camera_stops" {
315            std::f64::consts::LOG10_2
316        } else {
317            1.0
318        };
319        let mut sum = 0.0;
320        let offset = base.unwrap_or(0.0);
321        for (x, density) in &rows {
322            let model = curve.density(x * factor) + offset;
323            sum += (model - density) * (model - density);
324        }
325        let rms = (sum / rows.len() as f64).sqrt();
326        // The curve has to be able to *reach* the ink it was fitted to. Its
327        // lower asymptote is where it bottoms out — `d_min` on a negative,
328        // `d_max` on a reversal — and a curve whose asymptote sits past the
329        // lowest density it was fitted to cannot produce that part of the sheet
330        // at any exposure. That is not a tolerance question: it is a shape
331        // asked to stand on ground it cannot touch.
332        //
333        // It has been true twice. T-MAX 400's toe fitted to 0.2554 against a
334        // sheet whose lowest measured density was 0.2266, and EKTACHROME E100's
335        // clear density to 0.1816 against a curve that ended at 0.1418 still
336        // falling. Both were found by reading a residual plot, and both shipped
337        // before they were.
338        //
339        // A film's points are total density, so the base rides in the same sum
340        // and the two can disagree for a reason that is not the shape's. When
341        // the base is itself above the sheet's lowest point, that is the number
342        // to look at first, and it is reported as such rather than as a broken
343        // curve — the fit cannot be blamed for a floor it was handed.
344        let lowest = rows
345            .iter()
346            .map(|(_, density)| *density)
347            .fold(f64::INFINITY, f64::min);
348        // Two thousandths of density per point of ink: a line is 0.24 pt wide
349        // and a dense curve crosses four hundred points, so a hundredth is
350        // drawing and digitising, not shape.
351        const REACH: f64 = 1e-2;
352        let mut base_note = None;
353        if let Some(base) = base {
354            if base > lowest + REACH {
355                base_note = Some(format!(
356                    "{lowest:.4} is the sheet's lowest density and the declared base is \
357                     {base:.4}, so the curve bottoms out at the base and cannot reach the \
358                     ink — the base is the number to look at"
359                ));
360            }
361        }
362        // With the base itself in question the shape cannot be blamed, so the
363        // reach only fails when the floor the model was handed is fine.
364        let bottom = curve.d_min.min(curve.d_max) + offset;
365        if base_note.is_none() && bottom > lowest + REACH {
366            return Err(StockError::Check {
367                path: self.path.clone(),
368                stage: prefix.to_owned(),
369                message: format!(
370                    "the curve bottoms out at {bottom:.4}, above the lowest density it was \
371                     fitted to ({lowest:.4}) — it cannot reach that part of the sheet at any \
372                     exposure, which usually means one corner rate is doing two corners' work"
373                ),
374            });
375        }
376        let declared = self.float(&format!("{prefix}.rms")).ok();
377        let verdict = match declared {
378            Some(declared) if declared > 0.1 => Err(StockError::Check {
379                path: self.path.clone(),
380                stage: prefix.to_owned(),
381                message: format!(
382                    "declares an rms of {declared:.3}; a curve fitted to a digitised sheet \
383                     lands under 0.05, so that number would excuse anything"
384                ),
385            }),
386            // Ten per cent: the fit's own uncertainty. What this catches is a
387            // transposed decimal, a dropped minus or a swapped layer — not a
388            // digit in the third place, which the data does not resolve either.
389            Some(declared) if rms <= declared * 1.10 => Ok(format!(
390                "{} points, rms {rms:.4} against a declared {declared:.4}",
391                rows.len()
392            )),
393            Some(declared) => Err(StockError::Check {
394                path: self.path.clone(),
395                stage: prefix.to_owned(),
396                message: format!(
397                    "rms {rms:.4} against the declared {declared:.4} — the curve does not \
398                     reproduce the points it names, so one of the two is wrong"
399                ),
400            }),
401            None => Err(StockError::Check {
402                path: self.path.clone(),
403                stage: prefix.to_owned(),
404                message: format!(
405                    "names points but no `rms`; the fit's residual is {rms:.4} and cannot be \
406                     checked against anything"
407                ),
408            }),
409        };
410        match (verdict, base_note) {
411            (Ok(message), Some(note)) => Ok(format!("{message}; {note}")),
412            (other, _) => other,
413        }
414    }
415
416    /// The per-stock numbers that are not on the curve.
417    fn check_base(&self) -> Finding {
418        if self.is_film() {
419            match (
420                self.floats("base.density"),
421                self.floats("base.grains_per_um2"),
422            ) {
423                (Ok(density), Ok(grains)) if density.len() == 3 && grains.len() == 3 => {
424                    Finding::ok(
425                        "base",
426                        format!("status density {density:?}, grains {grains:?} per µm²"),
427                    )
428                }
429                _ => Finding::fail(
430                    "base",
431                    "a film needs `base.density` and `base.grains_per_um2`, three values each",
432                ),
433            }
434        } else {
435            match self.float("base.target_mid_reflectance") {
436                Ok(value) if (0.01..=1.0).contains(&value) => {
437                    Finding::ok("base", format!("target mid reflectance {value:.3}"))
438                }
439                Ok(value) => Finding::fail(
440                    "base",
441                    format!("target_mid_reflectance {value} is not a reflectance"),
442                ),
443                Err(_) => Finding::fail("base", "a paper needs `base.target_mid_reflectance`"),
444            }
445        }
446    }
447
448    /// The chemistry the stock declares, and whether its blocks can serve it.
449    ///
450    /// The requirement comes from the process, not from the checker's taste: a
451    /// reversal film is the picture and needs its own dyes, a monochrome film
452    /// has no dyes to give, and a negative borrows them when the sheet does not
453    /// print them.
454    fn check_process(&self) -> Finding {
455        let process = self
456            .string("stock.process")
457            .unwrap_or_else(|_| "negative".to_owned());
458        let dyes = self
459            .strings("spectra.dyes")
460            .map(|d| !d.is_empty())
461            .unwrap_or(false);
462        match process.as_str() {
463            "negative" => Finding::ok(
464                "process",
465                if dyes {
466                    "negative, dyes on the sheet".to_owned()
467                } else {
468                    "negative, dyes borrowed".to_owned()
469                },
470            ),
471            "reversal" if dyes => {
472                Finding::ok("reversal", "dyes are its own, and there is no print")
473            }
474            "reversal" => Finding::fail(
475                "reversal",
476                "a reversal film is its own picture: it needs `spectra.dyes`",
477            ),
478            "monochrome" if dyes => Finding::fail(
479                "monochrome",
480                "a silver film has no dyes; a chromogenic one needs a process this format lacks",
481            ),
482            "monochrome" => Finding::ok("monochrome", "one record, no dyes, no mask"),
483            other => Finding::fail(
484                "process",
485                format!("{other:?} is not negative, reversal or monochrome"),
486            ),
487        }
488    }
489
490    /// Which quantities the stock takes from another one, if any.
491    ///
492    /// A manufacturer sheet does not always publish every quantity the model
493    /// wants: Kodak's still films print a neutral subject and D-min rather than
494    /// three dyes. Borrowing is legitimate; borrowing silently is not. A stock
495    /// that declares a borrow can still claim to be measured, and the check
496    /// says what was taken.
497    fn check_borrowed(&self) -> Finding {
498        match self.strings("stock.borrowed") {
499            Ok(names) if names.is_empty() => {
500                Finding::note("borrowed", "nothing; every quantity is this sheet's")
501            }
502            Ok(names) => Finding::ok("borrowed", format!("{:?} from the compiled stock", names)),
503            Err(_) => Finding::note("borrowed", "nothing declared"),
504        }
505    }
506
507    /// The emulsion's blur, if the sheet publishes an MTF.
508    ///
509    /// Optional: a stock without a measured modulation-transfer curve is a
510    /// stock that borrows the blur, and saying so is better than inventing a
511    /// sigma. When present, three positive finite values in micrometres.
512    fn check_emulsion(&self) -> Finding {
513        if !self.is_film() {
514            return Finding::note("emulsion", "not a film");
515        }
516        let Ok(sigma) = self.floats("emulsion.sigma_um") else {
517            return Finding::note(
518                "emulsion",
519                "no `emulsion.sigma_um`; the stock falls back to the compiled blur",
520            );
521        };
522        if sigma.len() == 3 && sigma.iter().all(|s| s.is_finite() && *s > 0.0) {
523            Finding::ok(
524                "emulsion",
525                format!("sigma {:?} µm, measured from the sheet's MTF", sigma),
526            )
527        } else {
528            Finding::fail(
529                "emulsion",
530                "`emulsion.sigma_um` must be three positive finite numbers",
531            )
532        }
533    }
534
535    /// The cyan-sensitive inhibitor layer, when the sheet gives one (ADR 0018).
536    ///
537    /// A stock without it is not incomplete: most sheets do not publish a
538    /// fourth layer at all. The check says which of the two it is rather than
539    /// leaving the reader to infer it from the absence of a section.
540    fn check_cl_layer(&self) -> Finding {
541        let Ok(path) = self.string("cl_layer.path") else {
542            return Finding::note("cl layer", "none declared; only some sheets give one");
543        };
544        let path = self.path.parent().unwrap_or(Path::new(".")).join(path);
545        match check_spectrum(&path) {
546            Ok(peak) => Finding::ok(
547                "cl layer",
548                format!(
549                    "{}: 71 samples on the grid, peak {peak:.3} — inhibitor, not a dye",
550                    path.display()
551                ),
552            ),
553            Err(message) => Finding::fail("cl layer", format!("{}: {message}", path.display())),
554        }
555    }
556
557    /// The spectra, on the grid the model integrates over.
558    fn check_spectra(&self) -> Vec<Finding> {
559        let mut findings = Vec::new();
560        for section in ["spectra.dyes", "spectra.layers"] {
561            let Ok(paths) = self.strings(section) else {
562                findings.push(Finding::note(
563                    section,
564                    "not given; the stock falls back to the placeholder spectra",
565                ));
566                continue;
567            };
568            if paths.len() != 3 {
569                findings.push(Finding::fail(
570                    section,
571                    format!("{} files; a paper or film has three layers", paths.len()),
572                ));
573                continue;
574            }
575            let mut problems = Vec::new();
576            for name in &paths {
577                let path = self.path.parent().unwrap_or(Path::new(".")).join(name);
578                match check_spectrum(&path) {
579                    Ok(peak) => findings.push(Finding::ok(
580                        section,
581                        format!("{}: 71 samples on the grid, peak {peak:.3}", path.display()),
582                    )),
583                    Err(message) => problems.push(format!("{}: {message}", path.display())),
584                }
585            }
586            if !problems.is_empty() {
587                findings.push(Finding::fail(section, problems.join("; ")));
588            }
589        }
590        findings
591    }
592
593    // ---------------------------------------------------------------- access
594
595    fn require(&self, keys: &[&str]) -> Result<(), StockError> {
596        for key in keys {
597            if !self.table.contains_key(*key) {
598                return Err(self.fail(format!("missing required field `{key}`")));
599            }
600        }
601        Ok(())
602    }
603
604    fn fail(&self, message: String) -> StockError {
605        StockError::Check {
606            path: self.path.clone(),
607            stage: "parse".to_owned(),
608            message,
609        }
610    }
611
612    fn string(&self, key: &str) -> Result<String, StockError> {
613        match self.table.get(key) {
614            Some(Value::String(value)) => Ok(value.clone()),
615            Some(_) => Err(self.fail(format!("`{key}` is not a string"))),
616            None => Err(self.fail(format!("missing `{key}`"))),
617        }
618    }
619
620    fn float(&self, key: &str) -> Result<f64, StockError> {
621        match self.table.get(key) {
622            Some(Value::Number(value)) => Ok(*value),
623            Some(_) => Err(self.fail(format!("`{key}` is not a number"))),
624            None => Err(self.fail(format!("missing `{key}`"))),
625        }
626    }
627
628    fn integer(&self, key: &str) -> Result<u32, StockError> {
629        self.float(key).map(|value| value.max(0.0) as u32)
630    }
631
632    fn floats(&self, key: &str) -> Result<Vec<f64>, StockError> {
633        match self.table.get(key) {
634            Some(Value::List(values)) => values
635                .iter()
636                .map(|value| match value {
637                    Value::Number(number) => Ok(*number),
638                    _ => Err(self.fail(format!("`{key}` has a non-numeric entry"))),
639                })
640                .collect(),
641            Some(_) => Err(self.fail(format!("`{key}` is not a list"))),
642            None => Err(self.fail(format!("missing `{key}`"))),
643        }
644    }
645
646    fn strings(&self, key: &str) -> Result<Vec<String>, StockError> {
647        match self.table.get(key) {
648            Some(Value::List(values)) => values
649                .iter()
650                .map(|value| match value {
651                    Value::String(text) => Ok(text.clone()),
652                    _ => Err(self.fail(format!("`{key}` has a non-string entry"))),
653                })
654                .collect(),
655            Some(_) => Err(self.fail(format!("`{key}` is not a list"))),
656            None => Err(self.fail(format!("missing `{key}`"))),
657        }
658    }
659
660    fn curve(&self, prefix: &str) -> Result<CharacteristicCurve, StockError> {
661        let base = CharacteristicCurve::new(
662            self.float(&format!("{prefix}.d_min"))?,
663            self.float(&format!("{prefix}.d_max"))?,
664            self.float(&format!("{prefix}.gamma"))?,
665            self.float(&format!("{prefix}.log_e0"))?,
666        );
667        let base = match self.float(&format!("{prefix}.transition")) {
668            Ok(transition) if transition > 0.0 => base.with_transition(transition),
669            Ok(transition) => {
670                return Err(self.fail(format!(
671                    "{prefix}.transition is {transition}, which is not a transition"
672                )));
673            }
674            Err(_) => base,
675        };
676        // The two corners can turn at different rates; a stock that only says
677        // `transition` has said both. Either can be given alone, and the one
678        // not given keeps the shared value — which is what a sheet that fitted
679        // one corner and read the other off the shared shape means.
680        let mut curve = base;
681        for (key, is_toe) in [("toe_transition", true), ("shoulder_transition", false)] {
682            match self.float(&format!("{prefix}.{key}")) {
683                Ok(rate) if rate > 0.0 => {
684                    curve = if is_toe {
685                        curve.with_corners(rate, curve.shoulder_transition)
686                    } else {
687                        curve.with_corners(curve.toe_transition, rate)
688                    };
689                }
690                Ok(rate) => {
691                    return Err(self.fail(format!(
692                        "{prefix}.{key} is {rate}, which is not a transition"
693                    )));
694                }
695                Err(_) => {}
696            }
697        }
698        Ok(curve)
699    }
700}
701
702/// One line of the checker's report.
703#[derive(Debug, Clone, PartialEq, Eq)]
704pub struct Finding {
705    /// Which part of the stock it is about.
706    pub stage: String,
707    /// How it went.
708    pub verdict: Verdict,
709    /// What it found.
710    pub message: String,
711}
712
713/// How a check went.
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum Verdict {
716    /// Fine.
717    Ok,
718    /// Worth knowing, not a problem.
719    Note,
720    /// The stock is not usable as it stands.
721    Fail,
722}
723
724impl Finding {
725    fn ok(stage: impl Into<String>, message: impl Into<String>) -> Self {
726        Self {
727            stage: stage.into(),
728            verdict: Verdict::Ok,
729            message: message.into(),
730        }
731    }
732    fn note(stage: impl Into<String>, message: impl Into<String>) -> Self {
733        Self {
734            stage: stage.into(),
735            verdict: Verdict::Note,
736            message: message.into(),
737        }
738    }
739    fn fail(stage: impl Into<String>, message: impl Into<String>) -> Self {
740        Self {
741            stage: stage.into(),
742            verdict: Verdict::Fail,
743            message: message.into(),
744        }
745    }
746}
747
748impl fmt::Display for Finding {
749    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
750        let mark = match self.verdict {
751            Verdict::Ok => "ok  ",
752            Verdict::Note => "note",
753            Verdict::Fail => "FAIL",
754        };
755        write!(formatter, "  {mark}  {:<10} {}", self.stage, self.message)
756    }
757}
758
759/// What can go wrong reading a stock file.
760#[derive(Debug)]
761pub enum StockError {
762    /// The file could not be read.
763    Io {
764        /// Path that failed.
765        path: PathBuf,
766        /// The OS error.
767        source: std::io::Error,
768    },
769    /// A syntax error, with the line it was on.
770    Syntax {
771        /// Path that failed.
772        path: PathBuf,
773        /// One-based line number.
774        line: usize,
775        /// What the parser expected.
776        message: String,
777    },
778    /// A field that is missing, malformed or inconsistent.
779    Check {
780        /// Path that failed.
781        path: PathBuf,
782        /// Which part of the stock.
783        stage: String,
784        /// What was wrong.
785        message: String,
786    },
787}
788
789impl fmt::Display for StockError {
790    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
791        match self {
792            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
793            Self::Syntax {
794                path,
795                line,
796                message,
797            } => {
798                write!(formatter, "{}:{line}: {message}", path.display())
799            }
800            Self::Check {
801                path,
802                stage,
803                message,
804            } => {
805                write!(formatter, "{} [{stage}]: {message}", path.display())
806            }
807        }
808    }
809}
810
811impl std::error::Error for StockError {
812    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
813        match self {
814            Self::Io { source, .. } => Some(source),
815            _ => None,
816        }
817    }
818}
819
820// ------------------------------------------------------------------ parsing
821
822/// A value in a stock file.
823#[derive(Debug, Clone, PartialEq)]
824pub(crate) enum Value {
825    String(String),
826    Number(f64),
827    List(Vec<Value>),
828}
829
830pub(crate) fn parse(text: &str, path: &Path) -> Result<BTreeMap<String, Value>, StockError> {
831    let mut table = BTreeMap::new();
832    let mut section = String::new();
833    let lines: Vec<&str> = text.lines().collect();
834    let mut index = 0;
835    while index < lines.len() {
836        let raw = lines[index];
837        let line = index + 1;
838        let trimmed = raw.trim();
839        if trimmed.is_empty() || trimmed.starts_with('#') {
840            index += 1;
841            continue;
842        }
843        if let Some(rest) = trimmed.strip_prefix('[') {
844            let Some(name) = rest.strip_suffix(']') else {
845                return Err(StockError::Syntax {
846                    path: path.to_path_buf(),
847                    line,
848                    message: "a section header ends with `]`".to_owned(),
849                });
850            };
851            section = name.trim().to_owned();
852            index += 1;
853            continue;
854        }
855        let Some((key, value)) = trimmed.split_once('=') else {
856            return Err(StockError::Syntax {
857                path: path.to_path_buf(),
858                line,
859                message: "expected `key = value` or `[section]`".to_owned(),
860            });
861        };
862        let key = key.trim();
863        let full = if section.is_empty() {
864            key.to_owned()
865        } else {
866            format!("{section}.{key}")
867        };
868        // A list may span lines; everything else is one line, which is what
869        // keeps the parser this small.
870        let mut literal = value.trim().to_owned();
871        while literal.starts_with('[') && !literal.contains(']') {
872            index += 1;
873            let Some(next) = lines.get(index) else {
874                return Err(StockError::Syntax {
875                    path: path.to_path_buf(),
876                    line,
877                    message: "a list is not closed with `]`".to_owned(),
878                });
879            };
880            literal.push(' ');
881            literal.push_str(next.trim());
882        }
883        let parsed = parse_value(&literal).map_err(|message| StockError::Syntax {
884            path: path.to_path_buf(),
885            line,
886            message,
887        })?;
888        table.insert(full, parsed);
889        index += 1;
890    }
891    Ok(table)
892}
893
894fn parse_value(text: &str) -> Result<Value, String> {
895    let text = text.trim();
896    if let Some(rest) = text.strip_prefix('"') {
897        let Some(end) = rest.find('"') else {
898            return Err("a string ends with `\"`".to_owned());
899        };
900        let trailing = rest[end + 1..].trim();
901        if !trailing.is_empty() && !trailing.starts_with('#') {
902            return Err(format!("unexpected {trailing:?} after a string"));
903        }
904        return Ok(Value::String(rest[..end].to_owned()));
905    }
906    if let Some(rest) = text.strip_prefix('[') {
907        let Some(end) = rest.find(']') else {
908            return Err("a list ends with `]`".to_owned());
909        };
910        let mut values = Vec::new();
911        for item in rest[..end].split(',') {
912            let item = item.trim();
913            if item.is_empty() {
914                continue;
915            }
916            values.push(parse_value(item)?);
917        }
918        return Ok(Value::List(values));
919    }
920    // Strip a trailing comment from a bare number.
921    let bare = text.split('#').next().unwrap_or(text).trim();
922    bare.parse::<f64>()
923        .map(Value::Number)
924        .map_err(|_| format!("{bare:?} is not a number or a quoted string"))
925}
926
927// -------------------------------------------------------------------- files
928
929fn read_points(path: &Path) -> Result<Vec<(f64, f64)>, StockError> {
930    let text = std::fs::read_to_string(path).map_err(|source| StockError::Io {
931        path: path.to_path_buf(),
932        source,
933    })?;
934    let mut rows = Vec::new();
935    for line in text.lines().skip(1) {
936        let mut fields = line.split(',');
937        let (Some(x), Some(y)) = (fields.next(), fields.next()) else {
938            continue;
939        };
940        let (Ok(x), Ok(y)) = (x.trim().parse::<f64>(), y.trim().parse::<f64>()) else {
941            continue;
942        };
943        rows.push((x, y));
944    }
945    Ok(rows)
946}
947
948/// Check one spectrum file is on the model's grid, and return its peak.
949fn check_spectrum(path: &Path) -> Result<f64, String> {
950    let set = kallo_spectral::load_csv(path).map_err(|error| error.to_string())?;
951    if set.samples.is_empty() {
952        return Err("no columns".to_owned());
953    }
954    let grid = &set.grid;
955    if grid.values().len() != 71 {
956        return Err(format!("{} samples, not 71", grid.values().len()));
957    }
958    if (grid.start_nm() - 380.0).abs() > 1e-9 || (grid.step_nm() - 5.0).abs() > 1e-9 {
959        return Err(format!(
960            "starts at {} nm in {} nm steps, not 380 in 5",
961            grid.start_nm(),
962            grid.step_nm()
963        ));
964    }
965    let mut peak: f64 = 0.0;
966    for value in set.samples[0].reflectance.values() {
967        if !value.is_finite() || *value < 0.0 {
968            return Err(format!("value {value} is not a non-negative number"));
969        }
970        peak = peak.max(*value);
971    }
972    Ok(peak)
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    const EXAMPLE: &str = r#"
980# A stock, as data.
981[stock]
982id = "example"          # comment after a value
983label = "An example paper"
984kind = "paper"
985claim = "measured"
986
987[provenance]
988source = "A data sheet"
989publication = "E-0000"
990page = 7
991retrieved = "2026-11-07"
992checksum = "sha256:0000"
993
994[curve.red]
995d_min = 0.10
996d_max = 2.50
997gamma = 3.5
998log_e0 = -1.40
999transition = 7.0
1000
1001[curve.green]
1002d_min = 0.10
1003d_max = 2.50
1004gamma = 3.5
1005log_e0 = -1.40
1006
1007[curve.blue]
1008d_min = 0.12
1009d_max = 2.40
1010gamma = 4.0
1011log_e0 = -1.42
1012
1013[base]
1014target_mid_reflectance = 0.18
1015"#;
1016
1017    fn parsed() -> StockFile {
1018        let table = parse(EXAMPLE, Path::new("example.toml")).expect("parses");
1019        StockFile {
1020            path: PathBuf::from("example.toml"),
1021            table,
1022        }
1023    }
1024
1025    #[test]
1026    fn the_subset_parses() {
1027        let file = parsed();
1028        assert_eq!(file.id(), "example");
1029        assert_eq!(file.label(), "An example paper");
1030        assert!(!file.is_film());
1031        assert!(file.claims_measured());
1032        assert_eq!(file.float("curve.red.gamma").unwrap(), 3.5);
1033        // A missing transition falls back to the negative's default.
1034        let curve = file.curve("curve.green").expect("green");
1035        assert_eq!(curve.toe_transition, kallo_core::curve::TRANSITION);
1036        // And one that is given is kept.
1037        assert_eq!(file.curve("curve.red").unwrap().toe_transition, 7.0);
1038        assert_eq!(file.curve("curve.red").unwrap().shoulder_transition, 7.0);
1039    }
1040
1041    #[test]
1042    fn a_comment_does_not_join_the_value() {
1043        let file = parsed();
1044        assert_eq!(file.string("stock.id").unwrap(), "example");
1045    }
1046
1047    #[test]
1048    fn a_measured_stock_without_a_document_fails() {
1049        let text = EXAMPLE.replace("checksum = \"sha256:0000\"\n", "");
1050        let table = parse(&text, Path::new("t.toml")).expect("parses");
1051        let file = StockFile {
1052            path: PathBuf::from("t.toml"),
1053            table,
1054        };
1055        let finding = file.check_provenance();
1056        assert_eq!(finding.verdict, Verdict::Fail);
1057        assert!(
1058            finding.message.contains("provenance.checksum"),
1059            "{}",
1060            finding.message
1061        );
1062    }
1063
1064    #[test]
1065    fn a_placeholder_says_so_and_passes() {
1066        let text = EXAMPLE
1067            .replace("claim = \"measured\"", "claim = \"placeholder\"")
1068            .replace("checksum = \"sha256:0000\"\n", "");
1069        let table = parse(&text, Path::new("t.toml")).expect("parses");
1070        let file = StockFile {
1071            path: PathBuf::from("t.toml"),
1072            table,
1073        };
1074        let finding = file.check_provenance();
1075        assert_eq!(finding.verdict, Verdict::Note);
1076    }
1077
1078    #[test]
1079    fn a_curve_that_is_not_a_curve_fails() {
1080        let text = EXAMPLE.replace("d_min = 0.10", "d_min = 3.0");
1081        let table = parse(&text, Path::new("t.toml")).expect("parses");
1082        let file = StockFile {
1083            path: PathBuf::from("t.toml"),
1084            table,
1085        };
1086        let findings = file.check_curves();
1087        assert!(
1088            findings.iter().any(|f| f.verdict == Verdict::Fail),
1089            "{findings:?}"
1090        );
1091    }
1092
1093    #[test]
1094    fn a_paper_needs_its_target_and_a_film_its_grain() {
1095        let file = parsed();
1096        assert_eq!(file.check_base().verdict, Verdict::Ok);
1097        let text = EXAMPLE.replace("kind = \"paper\"", "kind = \"film\"");
1098        let table = parse(&text, Path::new("t.toml")).expect("parses");
1099        let film = StockFile {
1100            path: PathBuf::from("t.toml"),
1101            table,
1102        };
1103        assert_eq!(film.check_base().verdict, Verdict::Fail);
1104    }
1105
1106    #[test]
1107    fn a_syntax_error_names_its_line() {
1108        let error = parse(
1109            "[stock]\nid = \"x\"\nthis is not a pair\n",
1110            Path::new("t.toml"),
1111        )
1112        .expect_err("a line that is not a pair fails");
1113        let text = error.to_string();
1114        assert!(text.contains(":3:"), "{text}");
1115    }
1116
1117    #[test]
1118    fn a_number_and_a_string_are_not_confused() {
1119        let table = parse("[a]\nb = 1\nc = \"2\"\n", Path::new("t.toml")).expect("parses");
1120        let file = StockFile {
1121            path: PathBuf::from("t.toml"),
1122            table,
1123        };
1124        assert!(file.float("a.b").is_ok());
1125        assert!(file.float("a.c").is_err());
1126        assert!(file.string("a.c").is_ok());
1127        assert!(file.string("a.b").is_err());
1128    }
1129
1130    #[test]
1131    fn a_film_may_not_carry_its_base_in_the_curve() {
1132        let text = EXAMPLE.replace("kind = \"paper\"", "kind = \"film\"");
1133        let table = parse(&text, Path::new("t.toml")).expect("parses");
1134        let file = StockFile {
1135            path: PathBuf::from("t.toml"),
1136            table,
1137        };
1138        let findings = file.check_curves();
1139        assert!(
1140            findings
1141                .iter()
1142                .any(|f| f.verdict == Verdict::Fail && f.message.contains("dye above base")),
1143            "{findings:?}"
1144        );
1145    }
1146
1147    /// A data sheet plots base plus dye; the model's curve is dye alone, so the
1148    /// points are only reproduced once `base.density` is added back. This is the
1149    /// check that makes those three numbers mean something.
1150    #[test]
1151    fn a_films_points_are_base_plus_dye() {
1152        let root = std::env::temp_dir().join(format!("kallo-stock-{}", std::process::id()));
1153        std::fs::create_dir_all(&root).expect("a temporary directory");
1154        let layers = ["red", "green", "blue"];
1155        let curves = [
1156            CharacteristicCurve::new(0.0, 1.70, 0.48, -0.76),
1157            CharacteristicCurve::new(0.0, 2.00, 0.56, -0.73),
1158            CharacteristicCurve::new(0.0, 1.92, 0.55, -0.75),
1159        ];
1160        let base = [0.19, 0.58, 0.83];
1161
1162        let text = |base: [f64; 3]| {
1163            let mut text = String::from(
1164                "[stock]\nid = \"test_film\"\nlabel = \"A film\"\nkind = \"film\"\n\
1165                 claim = \"placeholder\"\n",
1166            );
1167            for (index, layer) in layers.iter().enumerate() {
1168                let name = format!("{layer}.csv");
1169                let mut csv = String::from("log_exposure,density\n");
1170                let mut x = -4.0;
1171                while x <= 1.0 {
1172                    let density = curves[index].density(x) + base[index];
1173                    csv.push_str(&format!("{x:.3},{density:.4}\n"));
1174                    x += 0.02;
1175                }
1176                std::fs::write(root.join(&name), csv).expect("a points file");
1177                let curve = &curves[index];
1178                text.push_str(&format!(
1179                    "\n[curve.{layer}]\nd_min = {:.4}\nd_max = {:.4}\ngamma = {:.4}\n\
1180                     log_e0 = {:.4}\npoints = \"{name}\"\nrms = 0.001\n",
1181                    curve.d_min, curve.d_max, curve.gamma, curve.log_e0
1182                ));
1183            }
1184            text.push_str(&format!(
1185                "\n[base]\ndensity = [{}, {}, {}]\ngrains_per_um2 = [1.0, 1.0, 1.0]\n",
1186                base[0], base[1], base[2]
1187            ));
1188            text
1189        };
1190
1191        let written = text(base);
1192        let right = root.join("right.toml");
1193        std::fs::write(&right, &written).expect("a stock file");
1194        let findings = StockFile::read(&right).expect("reads").check_curves();
1195        assert!(
1196            findings.iter().all(|f| f.verdict == Verdict::Ok),
1197            "{findings:?}"
1198        );
1199
1200        // The same points, with the base declared a tenth higher: the curve no
1201        // longer reaches the sheet's numbers, and the check has to say so.
1202        let wrong = root.join("wrong.toml");
1203        std::fs::write(
1204            &wrong,
1205            written.replace(
1206                "density = [0.19, 0.58, 0.83]",
1207                "density = [0.29, 0.58, 0.83]",
1208            ),
1209        )
1210        .expect("a stock file");
1211        let findings = StockFile::read(&wrong).expect("reads").check_curves();
1212        assert!(
1213            findings
1214                .iter()
1215                .any(|f| f.verdict == Verdict::Fail && f.message.contains("rms")),
1216            "{findings:?}"
1217        );
1218        std::fs::remove_dir_all(&root).expect("clean up");
1219    }
1220
1221    /// A curve that bottoms out above the sheet's own lowest density cannot be
1222    /// that curve at any exposure, and the check has to fail rather than report
1223    /// a residual.
1224    ///
1225    /// The numbers are EKTACHROME E100's first release: one corner rate, a clear
1226    /// density of 0.1816 fitted against a curve that ended at 0.1418 still
1227    /// falling. That stock shipped because nothing compared the asymptote with
1228    /// the ink; the two-lobe residual was the only symptom and it looked like
1229    /// noise. T-MAX 400's toe had the same defect a day earlier, at 0.2554
1230    /// against 0.2266.
1231    #[test]
1232    fn a_curve_may_not_bottom_out_above_its_own_ink() {
1233        let root = std::env::temp_dir().join(format!("kallo-reach-{}", std::process::id()));
1234        std::fs::create_dir_all(&root).expect("a temporary directory");
1235        // A reversal: density falls from 3.28 to wherever the curve is going,
1236        // and the sheet stops drawing at 0.14 while it is still falling.
1237        let said = CharacteristicCurve::new(3.2820, 0.0481, 2.5666, -1.3485).with_corners(5.0, 2.5);
1238        let mut csv = String::from("log_exposure,density\n");
1239        let mut x = -2.6;
1240        while x <= 0.2 {
1241            if said.density(x) >= 0.1418 {
1242                csv.push_str(&format!("{x:.3},{:.4}\n", said.density(x)));
1243            }
1244            x += 0.02;
1245        }
1246        std::fs::write(root.join("red.csv"), &csv).expect("a points file");
1247        let text = |d_max: f64, toe: f64, shoulder: f64| {
1248            format!(
1249                "[stock]\nid = \"test_reversal\"\nlabel = \"A reversal\"\nkind = \"film\"\n\
1250                 claim = \"placeholder\"\nprocess = \"reversal\"\n\
1251                 \n[curve.red]\nd_min = 3.2820\nd_max = {d_max:.4}\ngamma = 2.5666\n\
1252                 log_e0 = -1.3485\ntoe_transition = {toe:.1}\n\
1253                 shoulder_transition = {shoulder:.1}\npoints = \"red.csv\"\nrms = 0.020\n\
1254                 \n[base]\ndensity = [0.0, 0.0, 0.0]\ngrains_per_um2 = [3.6, 3.6, 3.6]\n"
1255            )
1256        };
1257        // The stock as it ships: the asymptote is below everything drawn.
1258        let good = root.join("good.toml");
1259        std::fs::write(&good, text(0.0481, 5.0, 2.5)).expect("a stock file");
1260        let findings = StockFile::read(&good).expect("reads").check_curves();
1261        assert!(
1262            findings
1263                .iter()
1264                .filter(|f| f.stage == "red")
1265                .all(|f| f.verdict == Verdict::Ok),
1266            "{findings:?}"
1267        );
1268
1269        // The first release: one rate, and an asymptote above the ink.
1270        let bad = root.join("bad.toml");
1271        std::fs::write(&bad, text(0.1816, 4.0, 4.0)).expect("a stock file");
1272        let findings = StockFile::read(&bad).expect("reads").check_curves();
1273        assert!(
1274            findings
1275                .iter()
1276                .any(|f| f.verdict == Verdict::Fail && f.message.contains("bottoms out")),
1277            "{findings:?}"
1278        );
1279        std::fs::remove_dir_all(&root).expect("clean up");
1280    }
1281}