Skip to main content

ras_commander_fast_map/
model.rs

1//! Request schema, validation and profile-source loading.
2use crate::MapError;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::io::BufReader;
7use std::path::{Path, PathBuf};
8
9pub const SESSION_SCHEMA: &str = "ras2fim-batch-session/1";
10pub const MAX_JSON_BYTES: u64 = 512 * 1024 * 1024;
11pub const MAX_BINARY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
12pub const MAX_PROFILE_PIXELS: u64 = 1 << 31;
13pub const MAX_THREADS: usize = 64;
14
15#[derive(Deserialize, Debug)]
16#[serde(deny_unknown_fields)]
17pub struct SessionRequest {
18    pub schema: String,
19    /// Declared CRS of every grid, sample and AOI bound. No reprojection is ever performed.
20    pub crs_epsg: u32,
21    pub terrain: TerrainSpec,
22    pub budgets: Budgets,
23    pub products: Vec<Product>,
24    pub profiles: Vec<ProfileSpec>,
25    pub aois: Vec<AoiSpec>,
26    pub error_policy: ErrorPolicy,
27}
28
29#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
30#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
31pub enum TerrainSpec {
32    /// Per-profile pre-aligned terrain buffers (old oracle parity).
33    CallerBuffer,
34    /// Native same-CRS north-up GeoTIFF sampled with an explicit convention.
35    NativeGeotiff {
36        path: String,
37        query: QueryConvention,
38        nodata_policy: NodataPolicy,
39        crs_identity: CrsIdentity,
40    },
41}
42
43/// How the terrain CRS is established equal to the session EPSG. Never inferred silently.
44#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
45#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
46pub enum CrsIdentity {
47    /// terrio must identify the terrain EPSG code itself and it must equal `crs_epsg`.
48    Epsg,
49    /// The caller independently verified the CRS (e.g. rasterio/PROJ identification) and pins
50    /// terrio's exact WKT by FNV-1a 64 hash (printed by `terrain-info`). A terrio-identified EPSG
51    /// that differs still fails.
52    CallerVerifiedWkt { fnv1a64: String },
53}
54
55#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum QueryConvention {
58    PixelCenter,
59    QueryPoint,
60}
61
62#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum NodataPolicy {
65    Dry,
66    RawValue,
67}
68
69#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum ErrorPolicy {
72    /// A failing profile marks only its own units failed (explicit, never an empty raster).
73    IsolateProfile,
74    /// Any profile failure aborts the session after recording it.
75    FailFast,
76}
77
78#[derive(Deserialize, Serialize, Debug, Clone)]
79#[serde(deny_unknown_fields)]
80pub struct Budgets {
81    pub threads: usize,
82    /// Decoded terrain-tile cache budget (bytes), shared by all profiles and AOIs.
83    pub max_cache_bytes: u64,
84    /// Prepared profiles alive at once.
85    pub max_inflight_profiles: usize,
86    /// Upper bound per prepared profile (interpolator + mask + caller terrain), bytes.
87    pub max_profile_bytes: u64,
88    /// Output tile edge in pixels (tiles anchored at each profile grid's pixel 0,0).
89    pub output_tile: usize,
90    /// Evaluated tiles held in memory at once (bounded queue between workers and sink).
91    /// 1 selects the serial (non-pipelined) evaluate-then-write branch.
92    pub max_tiles_in_flight: usize,
93    /// Optional ceiling on the session's *accounted* memory estimate (see `memory_estimate` in
94    /// the report). Checked before any profile is read; profiles are packed into batches so the
95    /// estimate fits. It is an admission check over accounted terms, not an OS-enforced limit:
96    /// unaccounted allocations (listed in the report) remain.
97    #[serde(default)]
98    pub max_session_bytes: Option<u64>,
99}
100
101#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
102#[serde(rename_all = "snake_case")]
103pub enum Product {
104    RawWse,
105    Wse,
106    Depth,
107    SimplexId,
108    Ground,
109}
110
111impl Product {
112    pub fn name(self) -> &'static str {
113        match self {
114            Product::RawWse => "raw_wse",
115            Product::Wse => "wse",
116            Product::Depth => "depth",
117            Product::SimplexId => "simplex_id",
118            Product::Ground => "ground",
119        }
120    }
121    pub fn dtype(self) -> &'static str {
122        match self {
123            Product::SimplexId => "<i8",
124            _ => "<f8",
125        }
126    }
127}
128
129#[derive(Deserialize, Debug, Clone)]
130#[serde(deny_unknown_fields)]
131pub struct ProfileSpec {
132    pub id: String,
133    pub source: ProfileSource,
134}
135
136#[derive(Deserialize, Debug, Clone)]
137#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
138pub enum ProfileSource {
139    /// Existing baseline prepared request (`prototype/evidence-*/**/*.request.json`). Its
140    /// samples, simplices, grid, mask and terrain are used; threads/repeats are ignored. In
141    /// native terrain mode its caller terrain buffer is ignored (reported).
142    LegacyRequest { path: String },
143    Inline {
144        samples: Vec<[f64; 3]>,
145        simplices: Vec<[usize; 3]>,
146        grid: GridSpec,
147        #[serde(default)]
148        mask: Option<Vec<bool>>,
149        #[serde(default)]
150        mask_all_wet: bool,
151        #[serde(default)]
152        terrain: Option<Vec<Option<f64>>>,
153    },
154    /// Raw little-endian arrays: samples n*3 f64, simplices m*3 u32, mask u8 (0/1) per pixel,
155    /// terrain f64 per pixel (NaN = nodata).
156    Binary {
157        samples_f64: String,
158        simplices_u32: String,
159        grid: GridSpec,
160        #[serde(default)]
161        mask_u8: Option<String>,
162        #[serde(default)]
163        mask_all_wet: bool,
164        #[serde(default)]
165        terrain_f64: Option<String>,
166    },
167}
168
169#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
170#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
171pub enum GridSpec {
172    /// Query point of pixel (row, col) is (x0 + col*dx, y0 + row*dy).
173    Explicit {
174        width: usize,
175        height: usize,
176        x0: f64,
177        y0: f64,
178        dx: f64,
179        dy: f64,
180    },
181    /// Query points are exactly the native terrain pixel centers of a terrain window; terrain is
182    /// sampled by integer index (native mode only).
183    TerrainAligned {
184        col: usize,
185        row: usize,
186        width: usize,
187        height: usize,
188    },
189}
190
191#[derive(Deserialize, Debug, Clone)]
192#[serde(deny_unknown_fields)]
193pub struct LegacyGrid {
194    pub width: usize,
195    pub height: usize,
196    pub x0: f64,
197    pub y0: f64,
198    pub dx: f64,
199    pub dy: f64,
200}
201
202#[derive(Deserialize, Debug)]
203#[serde(deny_unknown_fields)]
204struct LegacyRequest {
205    samples: Vec<[f64; 3]>,
206    grid: LegacyGrid,
207    mask: Vec<bool>,
208    terrain: Vec<Option<f64>>,
209    simplices: Option<Vec<[usize; 3]>>,
210    #[serde(default)]
211    allow_native_triangulation: bool,
212    #[allow(dead_code)]
213    threads: usize,
214    #[allow(dead_code)]
215    repeats: usize,
216}
217
218#[derive(Deserialize, Serialize, Debug, Clone)]
219#[serde(deny_unknown_fields)]
220pub struct AoiSpec {
221    pub id: String,
222    /// [col, row, width, height] on each profile's own grid.
223    #[serde(default)]
224    pub window: Option<[u64; 4]>,
225    /// [xmin, ymin, xmax, ymax]; selects pixels whose center lies in [min, max).
226    #[serde(default)]
227    pub bounds: Option<[f64; 4]>,
228    #[serde(default)]
229    pub crs_epsg: Option<u32>,
230}
231
232/// Loaded profile data, before interpolator preparation.
233pub struct ProfileData {
234    pub samples: Vec<[f64; 3]>,
235    pub simplices: Vec<[usize; 3]>,
236    pub grid: GridSpec,
237    pub mask: MaskData,
238    pub terrain: Option<Vec<f64>>,
239    pub legacy_terrain_ignored: bool,
240    pub source_bytes: u64,
241}
242
243pub enum MaskData {
244    AllWet,
245    Bits(Vec<bool>),
246}
247
248/// Windows device names; reserved with any extension (`NUL.txt`) and in any case.
249const WINDOWS_RESERVED: [&str; 4] = ["CON", "PRN", "AUX", "NUL"];
250
251fn windows_reserved(id: &str) -> bool {
252    let stem = id.split('.').next().unwrap_or(id).to_ascii_uppercase();
253    WINDOWS_RESERVED.contains(&stem.as_str())
254        || (stem.len() == 4
255            && (stem.starts_with("COM") || stem.starts_with("LPT"))
256            && stem.as_bytes()[3].is_ascii_digit())
257}
258
259/// Ids name output directories, so they must be path-safe on Linux and Windows/NTFS (including
260/// drvfs/SMB): `[A-Za-z0-9_.-]{1,64}`, not `.`/`..`, no trailing `.`, no Windows device name.
261/// Uniqueness is checked case-insensitively by the caller (`id_key`).
262pub fn validate_id(id: &str) -> Result<(), MapError> {
263    let charset = !id.is_empty()
264        && id.len() <= 64
265        && id
266            .chars()
267            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
268    if !charset {
269        return Err(MapError::Invalid(format!(
270            "id {id:?} must match [A-Za-z0-9_.-]{{1,64}}"
271        )));
272    }
273    if id.ends_with('.') {
274        return Err(MapError::Invalid(format!(
275            "id {id:?} must not end with '.' (Windows strips it; also rejects . and ..)"
276        )));
277    }
278    if windows_reserved(id) {
279        return Err(MapError::Invalid(format!(
280            "id {id:?} is a reserved Windows device name"
281        )));
282    }
283    Ok(())
284}
285
286/// Case-insensitive identity of an id (two ids with equal keys collide on NTFS).
287pub fn id_key(id: &str) -> String {
288    id.to_ascii_lowercase()
289}
290
291impl SessionRequest {
292    pub fn load(path: &Path) -> Result<Self, MapError> {
293        let r: SessionRequest = read_json(path)?;
294        r.validate()?;
295        Ok(r)
296    }
297
298    pub fn validate(&self) -> Result<(), MapError> {
299        if self.schema != SESSION_SCHEMA {
300            return Err(MapError::Invalid(format!(
301                "schema must be {SESSION_SCHEMA:?}"
302            )));
303        }
304        let b = &self.budgets;
305        if b.threads == 0 || b.threads > MAX_THREADS {
306            return Err(MapError::Invalid(format!(
307                "threads must be 1..={MAX_THREADS}"
308            )));
309        }
310        if !(16..=4096).contains(&b.output_tile) {
311            return Err(MapError::Invalid("output_tile must be 16..=4096".into()));
312        }
313        if b.max_tiles_in_flight == 0 || b.max_tiles_in_flight > 65_536 {
314            return Err(MapError::Invalid(
315                "max_tiles_in_flight must be 1..=65536".into(),
316            ));
317        }
318        if b.max_inflight_profiles == 0 || b.max_inflight_profiles > 4096 {
319            return Err(MapError::Invalid(
320                "max_inflight_profiles must be 1..=4096".into(),
321            ));
322        }
323        if b.max_profile_bytes == 0 {
324            return Err(MapError::Invalid(
325                "max_profile_bytes must be positive".into(),
326            ));
327        }
328        if b.max_session_bytes == Some(0) {
329            return Err(MapError::Invalid(
330                "max_session_bytes must be positive when given".into(),
331            ));
332        }
333        let out_bytes = self.output_buffer_bound()?;
334        if out_bytes > 16 * 1024 * 1024 * 1024 {
335            return Err(MapError::Budget(format!(
336                "tile queue bound {out_bytes} bytes exceeds 16 GiB"
337            )));
338        }
339        if self.products.is_empty() {
340            return Err(MapError::Invalid("at least one product required".into()));
341        }
342        let unique: HashSet<_> = self.products.iter().collect();
343        if unique.len() != self.products.len() {
344            return Err(MapError::Invalid("duplicate product".into()));
345        }
346        if self.profiles.is_empty() || self.aois.is_empty() {
347            return Err(MapError::Invalid(
348                "at least one profile and one AOI required".into(),
349            ));
350        }
351        let mut ids = HashSet::new();
352        for p in &self.profiles {
353            validate_id(&p.id)?;
354            if !ids.insert(id_key(&p.id)) {
355                return Err(MapError::Invalid(format!(
356                    "duplicate profile id {} (ids are compared case-insensitively)",
357                    p.id
358                )));
359            }
360        }
361        let mut ids = HashSet::new();
362        for a in &self.aois {
363            validate_id(&a.id)?;
364            if !ids.insert(id_key(&a.id)) {
365                return Err(MapError::Invalid(format!(
366                    "duplicate AOI id {} (ids are compared case-insensitively)",
367                    a.id
368                )));
369            }
370            if a.window.is_some() && a.bounds.is_some() {
371                return Err(MapError::Invalid(format!(
372                    "AOI {}: window and bounds are exclusive",
373                    a.id
374                )));
375            }
376            if let Some(w) = a.window
377                && (w[2] == 0
378                    || w[3] == 0
379                    || w[0].checked_add(w[2]).is_none()
380                    || w[1].checked_add(w[3]).is_none())
381            {
382                return Err(MapError::Invalid(format!("AOI {}: invalid window", a.id)));
383            }
384            if let Some(bd) = a.bounds
385                && (!bd.iter().all(|v| v.is_finite()) || bd[0] >= bd[2] || bd[1] >= bd[3])
386            {
387                return Err(MapError::Invalid(format!(
388                    "AOI {}: bounds must be finite, min < max",
389                    a.id
390                )));
391            }
392            if a.crs_epsg.is_some_and(|e| e != self.crs_epsg) {
393                return Err(MapError::Unsupported(format!(
394                    "AOI {} CRS EPSG:{} differs from session EPSG:{}; no reprojection",
395                    a.id,
396                    a.crs_epsg.unwrap_or_default(),
397                    self.crs_epsg
398                )));
399            }
400        }
401        Ok(())
402    }
403
404    /// Evaluated tiles that can actually be alive at once: 1 in the serial branch
405    /// (`max_tiles_in_flight == 1`), otherwise two pipelined chunks of `max_tiles_in_flight / 2`.
406    pub fn effective_tiles_in_flight(&self) -> usize {
407        match self.budgets.max_tiles_in_flight {
408            1 => 1,
409            m => 2 * (m / 2),
410        }
411    }
412
413    /// Bytes of evaluated tile buffers that can be alive between workers and sink
414    /// (`effective_tiles_in_flight x output_tile^2 x 40`; five 8-byte buffers per pixel).
415    pub fn output_buffer_bound(&self) -> Result<u64, MapError> {
416        let px = (self.budgets.output_tile as u64).pow(2);
417        (self.effective_tiles_in_flight() as u64)
418            .checked_mul(px)
419            .and_then(|v| v.checked_mul(8 * 5))
420            .ok_or_else(|| MapError::Budget("tile queue bound overflows".into()))
421    }
422
423    /// Exact bytes of profile payloads held inside the parsed request (inline sources), which
424    /// stay resident for the whole session.
425    pub fn inline_payload_bytes(&self) -> u64 {
426        self.profiles
427            .iter()
428            .map(|p| match &p.source {
429                ProfileSource::Inline {
430                    samples,
431                    simplices,
432                    mask,
433                    terrain,
434                    ..
435                } => {
436                    24 * samples.len() as u64
437                        + 8 * 3 * simplices.len() as u64
438                        + mask.as_ref().map_or(0, |m| m.len() as u64)
439                        + 16 * terrain.as_ref().map_or(0, |t| t.len() as u64)
440                }
441                _ => 0,
442            })
443            .sum()
444    }
445}
446
447/// Pre-read memory estimate for loading and preparing one profile.
448#[derive(Serialize, Clone, Debug, Default, PartialEq)]
449pub struct ProfileEstimate {
450    /// How the numbers were obtained.
451    pub basis: &'static str,
452    /// Bytes kept while the profile is alive (prepared interpolator, mask, caller terrain).
453    /// For `binary`/`inline` this mirrors the post-prepare self-accounting with every supplied
454    /// simplex assumed valid.
455    pub resident_bytes: u64,
456    /// Extra bytes alive only during load + prepare (decoded samples and simplices, triangle
457    /// vector and an assumed equal-size `RTree::bulk_load` working copy, read buffer).
458    pub transient_bytes: u64,
459    /// Source bytes on disk (files) or 0 (inline).
460    pub source_bytes: u64,
461}
462
463impl ProfileEstimate {
464    pub fn peak_bytes(&self) -> u64 {
465        self.resident_bytes.saturating_add(self.transient_bytes)
466    }
467}
468
469/// Bytes per JSON byte assumed for a fully parsed legacy request plus its preparation. Derived
470/// from the worst-case textual densities (e.g. `0,` -> 16-byte `Option<f64>`, `[1,2,3],` -> 24-byte
471/// simplex plus prepared triangle and bulk-load copy) with 2x `Vec` growth slack. A conservative
472/// heuristic, not a proof: use `binary` sources where a tight pre-read estimate matters.
473pub const LEGACY_JSON_PEAK_FACTOR: u64 = 64;
474/// Chunk size of the streaming binary decoder.
475pub const READ_CHUNK_BYTES: u64 = 1 << 20;
476
477fn file_len(path: &Path) -> Result<u64, MapError> {
478    Ok(fs::metadata(path)
479        .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?
480        .len())
481}
482
483/// Length of a little-endian record file after limit and multiple checks (no read).
484fn binary_len(path: &Path, item: u64) -> Result<u64, MapError> {
485    let len = file_len(path)?;
486    if len > MAX_BINARY_BYTES {
487        return Err(MapError::Budget(format!(
488            "{} exceeds binary limit",
489            path.display()
490        )));
491    }
492    if len % item != 0 {
493        return Err(MapError::Invalid(format!(
494            "{}: length {len} is not a multiple of {item}",
495            path.display()
496        )));
497    }
498    Ok(len)
499}
500
501/// Streams `N`-byte little-endian records straight into their final vector (no whole-file
502/// buffer). `len` comes from `binary_len`; a file that changes size meanwhile is an error.
503fn read_records<const N: usize, T>(
504    path: &Path,
505    len: u64,
506    mut decode: impl FnMut(&[u8; N]) -> Result<T, MapError>,
507) -> Result<Vec<T>, MapError> {
508    use std::io::Read;
509    let io = |e: std::io::Error| MapError::Io(format!("{}: {e}", path.display()));
510    let count = (len / N as u64) as usize;
511    let mut f = fs::File::open(path).map_err(io)?;
512    let mut out = Vec::with_capacity(count);
513    let per_chunk = (READ_CHUNK_BYTES as usize / N).max(1);
514    let mut buf = vec![0u8; per_chunk * N];
515    while out.len() < count {
516        let n = (count - out.len()).min(per_chunk) * N;
517        f.read_exact(&mut buf[..n]).map_err(io)?;
518        for c in buf[..n].as_chunks::<N>().0 {
519            out.push(decode(c)?);
520        }
521    }
522    if f.read(&mut [0u8; 1]).map_err(io)? != 0 {
523        return Err(MapError::Invalid(format!(
524            "{}: file grew while reading",
525            path.display()
526        )));
527    }
528    Ok(out)
529}
530
531pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, MapError> {
532    let len = fs::metadata(path)
533        .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?
534        .len();
535    if len > MAX_JSON_BYTES {
536        return Err(MapError::Budget(format!(
537            "{} exceeds 512 MiB JSON limit",
538            path.display()
539        )));
540    }
541    let f = fs::File::open(path).map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?;
542    serde_json::from_reader(BufReader::new(f))
543        .map_err(|e| MapError::Invalid(format!("{}: {e}", path.display())))
544}
545
546pub fn resolve(base: &Path, p: &str) -> PathBuf {
547    let p = Path::new(p);
548    if p.is_absolute() {
549        p.to_path_buf()
550    } else {
551        base.join(p)
552    }
553}
554
555impl GridSpec {
556    pub fn dims(&self) -> (usize, usize) {
557        match *self {
558            GridSpec::Explicit { width, height, .. }
559            | GridSpec::TerrainAligned { width, height, .. } => (width, height),
560        }
561    }
562    pub fn pixels(&self) -> Result<u64, MapError> {
563        let (w, h) = self.dims();
564        let n = (w as u64)
565            .checked_mul(h as u64)
566            .ok_or_else(|| MapError::Invalid("grid dimensions overflow".into()))?;
567        if n == 0 || n > MAX_PROFILE_PIXELS {
568            return Err(MapError::Budget(format!(
569                "grid must contain 1..={MAX_PROFILE_PIXELS} pixels"
570            )));
571        }
572        Ok(n)
573    }
574}
575
576impl ProfileSource {
577    /// Loads the source into memory. `native` controls the legacy terrain handling.
578    pub fn load(&self, base: &Path, native: bool) -> Result<ProfileData, MapError> {
579        match self {
580            ProfileSource::LegacyRequest { path } => {
581                let path = resolve(base, path);
582                let bytes = fs::metadata(&path)?.len();
583                let r: LegacyRequest = read_json(&path)?;
584                let simplices = r.simplices.ok_or_else(|| {
585                    MapError::Unsupported(
586                        "legacy request has no supplied simplices; native triangulation is not available in this prototype".into(),
587                    )
588                })?;
589                let _ = r.allow_native_triangulation;
590                let g = r.grid;
591                let terrain = if native {
592                    None
593                } else {
594                    Some(r.terrain.iter().map(|v| v.unwrap_or(f64::NAN)).collect())
595                };
596                Ok(ProfileData {
597                    samples: r.samples,
598                    simplices,
599                    grid: GridSpec::Explicit {
600                        width: g.width,
601                        height: g.height,
602                        x0: g.x0,
603                        y0: g.y0,
604                        dx: g.dx,
605                        dy: g.dy,
606                    },
607                    mask: MaskData::Bits(r.mask),
608                    terrain,
609                    legacy_terrain_ignored: native,
610                    source_bytes: bytes,
611                })
612            }
613            ProfileSource::Inline {
614                samples,
615                simplices,
616                grid,
617                mask,
618                mask_all_wet,
619                terrain,
620            } => Ok(ProfileData {
621                samples: samples.clone(),
622                simplices: simplices.clone(),
623                grid: grid.clone(),
624                mask: mask_data(mask.clone(), *mask_all_wet)?,
625                terrain: terrain
626                    .as_ref()
627                    .map(|t| t.iter().map(|v| v.unwrap_or(f64::NAN)).collect()),
628                legacy_terrain_ignored: false,
629                source_bytes: 0,
630            }),
631            ProfileSource::Binary {
632                samples_f64,
633                simplices_u32,
634                grid,
635                mask_u8,
636                mask_all_wet,
637                terrain_f64,
638            } => {
639                // Lengths are re-checked here (the estimate may be stale) before any read.
640                let lens = binary_lens(self, base)?;
641                let f64_at = |c: &[u8], i: usize| {
642                    let mut b = [0u8; 8];
643                    b.copy_from_slice(&c[i * 8..i * 8 + 8]);
644                    f64::from_le_bytes(b)
645                };
646                let samples = read_records::<24, _>(&resolve(base, samples_f64), lens[0], |c| {
647                    Ok([f64_at(c, 0), f64_at(c, 1), f64_at(c, 2)])
648                })?;
649                let simplices =
650                    read_records::<12, _>(&resolve(base, simplices_u32), lens[1], |c| {
651                        let u = |i: usize| {
652                            let mut b = [0u8; 4];
653                            b.copy_from_slice(&c[i * 4..i * 4 + 4]);
654                            u32::from_le_bytes(b) as usize
655                        };
656                        Ok([u(0), u(1), u(2)])
657                    })?;
658                let mask = match mask_u8 {
659                    Some(p) => Some(read_records::<1, _>(
660                        &resolve(base, p),
661                        lens[2],
662                        |c| match c[0] {
663                            0 => Ok(false),
664                            1 => Ok(true),
665                            v => Err(MapError::Invalid(format!("mask byte {v} is not 0/1"))),
666                        },
667                    )?),
668                    None => None,
669                };
670                let terrain = match terrain_f64 {
671                    Some(p) => Some(read_records::<8, _>(&resolve(base, p), lens[3], |c| {
672                        Ok(f64::from_le_bytes(*c))
673                    })?),
674                    None => None,
675                };
676                Ok(ProfileData {
677                    samples,
678                    simplices,
679                    grid: grid.clone(),
680                    mask: mask_data(mask, *mask_all_wet)?,
681                    terrain,
682                    legacy_terrain_ignored: false,
683                    source_bytes: lens.iter().sum(),
684                })
685            }
686        }
687    }
688
689    /// Memory estimate computed from request content and file lengths only (nothing is read).
690    /// Binary sources also fail here, before any allocation, when mask/terrain lengths do not
691    /// match the declared grid.
692    pub fn estimate(&self, base: &Path) -> Result<ProfileEstimate, MapError> {
693        let per_simplex = crate::interp::prepared_bytes_per_simplex();
694        let tri = crate::interp::triangle_bytes();
695        // Prepared structure + inputs alive during preparation (samples, simplices, triangle
696        // vector, and an assumed equal-size bulk-load working copy).
697        let shape = |n: u64, m: u64, mask: u64, terrain_px: u64| {
698            let resident = m
699                .saturating_mul(per_simplex)
700                .saturating_add(mask)
701                .saturating_add(terrain_px.saturating_mul(8));
702            let transient = n
703                .saturating_mul(24)
704                .saturating_add(m.saturating_mul(24))
705                .saturating_add(m.saturating_mul(tri).saturating_mul(2));
706            (resident, transient)
707        };
708        match self {
709            ProfileSource::LegacyRequest { path } => {
710                let len = file_len(&resolve(base, path))?;
711                if len > MAX_JSON_BYTES {
712                    return Err(MapError::Budget(format!(
713                        "{path} exceeds 512 MiB JSON limit"
714                    )));
715                }
716                let peak = len.saturating_mul(LEGACY_JSON_PEAK_FACTOR);
717                Ok(ProfileEstimate {
718                    basis: "legacy_json_length_x64_heuristic",
719                    resident_bytes: peak / 2,
720                    transient_bytes: peak - peak / 2,
721                    source_bytes: len,
722                })
723            }
724            ProfileSource::Inline {
725                samples,
726                simplices,
727                mask,
728                terrain,
729                ..
730            } => {
731                let (resident, transient) = shape(
732                    samples.len() as u64,
733                    simplices.len() as u64,
734                    mask.as_ref().map_or(0, |m| m.len() as u64),
735                    terrain.as_ref().map_or(0, |t| t.len() as u64),
736                );
737                Ok(ProfileEstimate {
738                    basis: "inline_exact_lengths",
739                    resident_bytes: resident,
740                    transient_bytes: transient,
741                    source_bytes: 0,
742                })
743            }
744            ProfileSource::Binary { .. } => {
745                let lens = binary_lens(self, base)?;
746                let (resident, transient) = shape(lens[0] / 24, lens[1] / 12, lens[2], lens[3] / 8);
747                Ok(ProfileEstimate {
748                    basis: "binary_file_lengths",
749                    resident_bytes: resident,
750                    transient_bytes: transient.saturating_add(READ_CHUNK_BYTES),
751                    source_bytes: lens.iter().sum(),
752                })
753            }
754        }
755    }
756}
757
758/// [samples, simplices, mask, terrain] byte lengths of a binary source, validated against the
759/// declared grid without reading the files.
760fn binary_lens(src: &ProfileSource, base: &Path) -> Result<[u64; 4], MapError> {
761    let ProfileSource::Binary {
762        samples_f64,
763        simplices_u32,
764        grid,
765        mask_u8,
766        terrain_f64,
767        ..
768    } = src
769    else {
770        return Err(MapError::Internal(
771            "binary_lens on a non-binary source".into(),
772        ));
773    };
774    let px = grid.pixels()?;
775    let s = binary_len(&resolve(base, samples_f64), 24)?;
776    let t = binary_len(&resolve(base, simplices_u32), 12)?;
777    let m = match mask_u8 {
778        Some(p) => {
779            let len = binary_len(&resolve(base, p), 1)?;
780            if len != px {
781                return Err(MapError::Invalid(format!(
782                    "mask has {len} values, grid has {px}"
783                )));
784            }
785            len
786        }
787        None => 0,
788    };
789    let g = match terrain_f64 {
790        Some(p) => {
791            let len = binary_len(&resolve(base, p), 8)?;
792            if len / 8 != px {
793                return Err(MapError::Invalid(format!(
794                    "terrain buffer has {} values, grid has {px}",
795                    len / 8
796                )));
797            }
798            len
799        }
800        None => 0,
801    };
802    Ok([s, t, m, g])
803}
804
805fn mask_data(mask: Option<Vec<bool>>, all: bool) -> Result<MaskData, MapError> {
806    match (mask, all) {
807        (Some(m), false) => Ok(MaskData::Bits(m)),
808        (None, true) => Ok(MaskData::AllWet),
809        _ => Err(MapError::Invalid(
810            "exactly one of mask / mask_all_wet must be given".into(),
811        )),
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn ids_are_path_safe() {
821        assert!(validate_id("wsel_max_1").is_ok());
822        assert!(validate_id("..").is_err());
823        assert!(validate_id("a/b").is_err());
824        assert!(validate_id("").is_err());
825    }
826
827    #[test]
828    fn ids_are_windows_safe() {
829        for bad in [
830            ".", "..", "a.", "reach.", "CON", "con", "Nul", "aux.txt", "PRN.x.y", "COM1", "com9",
831            "LPT1", "lpt3.bin", "COM0",
832        ] {
833            assert!(validate_id(bad).is_err(), "{bad}");
834        }
835        for ok in [
836            "CONX",
837            "console",
838            "COM10",
839            "LPT",
840            "nul_x",
841            "a.b",
842            ".hidden",
843            "wsel_max_10",
844            "COMA",
845        ] {
846            assert!(validate_id(ok).is_ok(), "{ok}");
847        }
848        assert_eq!(id_key("Reach_A"), id_key("reach_a"));
849    }
850
851    fn tiny(id: &str) -> serde_json::Value {
852        serde_json::json!({"id": id, "source": {"kind": "inline", "samples": [[0,0,1],[1,0,1],[0,1,1]],
853            "simplices": [[0,1,2]], "grid": {"kind": "explicit", "width": 2, "height": 2, "x0": 0, "y0": 0, "dx": 0.4, "dy": 0.4},
854            "mask_all_wet": true, "terrain": [0, 0, 0, 0]}})
855    }
856
857    fn session(profiles: Vec<serde_json::Value>, aois: serde_json::Value) -> SessionRequest {
858        serde_json::from_value(serde_json::json!({"schema": SESSION_SCHEMA, "crs_epsg": 2277,
859            "terrain": {"mode": "caller_buffer"},
860            "budgets": {"threads": 1, "max_cache_bytes": 0, "max_inflight_profiles": 1, "max_profile_bytes": 1 << 20,
861                        "output_tile": 16, "max_tiles_in_flight": 1},
862            "products": ["depth"], "profiles": profiles, "aois": aois, "error_policy": "isolate_profile"}))
863        .unwrap()
864    }
865
866    #[test]
867    fn case_colliding_ids_are_rejected() {
868        let s = session(
869            vec![tiny("A"), tiny("a")],
870            serde_json::json!([{"id": "full"}]),
871        );
872        assert!(
873            s.validate()
874                .unwrap_err()
875                .to_string()
876                .contains("case-insensitive")
877        );
878        let s = session(
879            vec![tiny("A")],
880            serde_json::json!([{"id": "R"}, {"id": "r"}]),
881        );
882        assert!(s.validate().is_err());
883        let s = session(vec![tiny("A")], serde_json::json!([{"id": "NUL"}]));
884        assert!(s.validate().is_err());
885        let s = session(
886            vec![tiny("A"), tiny("B")],
887            serde_json::json!([{"id": "full"}]),
888        );
889        assert!(s.validate().is_ok());
890        assert_eq!(s.effective_tiles_in_flight(), 1);
891        assert_eq!(s.output_buffer_bound().unwrap(), 16 * 16 * 40);
892    }
893
894    #[test]
895    fn effective_in_flight_matches_pipeline() {
896        let mut s = session(vec![tiny("A")], serde_json::json!([{"id": "full"}]));
897        for (m, e) in [(1, 1), (2, 2), (3, 2), (5, 4), (16, 16)] {
898            s.budgets.max_tiles_in_flight = m;
899            assert_eq!(s.effective_tiles_in_flight(), e, "{m}");
900        }
901    }
902
903    #[test]
904    fn binary_estimate_needs_no_read_and_checks_lengths() {
905        let d = std::env::temp_dir().join(format!("ras2fim-model-est-{}", std::process::id()));
906        let _ = fs::remove_dir_all(&d);
907        fs::create_dir_all(&d).unwrap();
908        // 1000 samples, 2000 simplices; a 4-pixel grid and a mask of the wrong length.
909        fs::write(d.join("s.f64"), vec![0u8; 24_000]).unwrap();
910        fs::write(d.join("t.u32"), vec![0u8; 24_000]).unwrap();
911        fs::write(d.join("m.u8"), vec![1u8; 5]).unwrap();
912        let src = |mask: bool| -> ProfileSource {
913            serde_json::from_value(serde_json::json!({"kind": "binary", "samples_f64": "s.f64",
914                "simplices_u32": "t.u32", "grid": {"kind": "explicit", "width": 2, "height": 2,
915                "x0": 0, "y0": 0, "dx": 1, "dy": 1}, "mask_u8": if mask {Some("m.u8")} else {None},
916                "mask_all_wet": !mask}))
917            .unwrap()
918        };
919        let e = src(false).estimate(&d).unwrap();
920        let tri = crate::interp::triangle_bytes();
921        assert_eq!(
922            e.resident_bytes,
923            2000 * crate::interp::prepared_bytes_per_simplex()
924        );
925        assert_eq!(
926            e.transient_bytes,
927            1000 * 24 + 2000 * 24 + 2 * 2000 * tri + READ_CHUNK_BYTES
928        );
929        assert_eq!(e.source_bytes, 48_000);
930        let err = src(true).estimate(&d).unwrap_err();
931        assert!(
932            err.to_string().contains("mask has 5 values, grid has 4"),
933            "{err}"
934        );
935        let _ = fs::remove_dir_all(&d);
936    }
937
938    #[test]
939    fn grid_pixel_limits() {
940        let g = GridSpec::Explicit {
941            width: usize::MAX,
942            height: 2,
943            x0: 0.,
944            y0: 0.,
945            dx: 1.,
946            dy: 1.,
947        };
948        assert!(g.pixels().is_err());
949        let g = GridSpec::TerrainAligned {
950            col: 0,
951            row: 0,
952            width: 0,
953            height: 2,
954        };
955        assert!(g.pixels().is_err());
956    }
957
958    #[test]
959    fn mask_exclusive() {
960        assert!(mask_data(None, false).is_err());
961        assert!(mask_data(Some(vec![true]), true).is_err());
962    }
963}