Skip to main content

arcsec_core/index/
format.rs

1//! The on-disk blind index: `ARCSECIX` version 1.
2//!
3//! One file, memory-mapped, little-endian throughout, every section 8-byte aligned.
4//! Opening it checks the header (magic, version, byte-order marker, its own CRC) and
5//! that every section lies inside the file with the size its counts imply — a few
6//! hundred bytes of work, so a 1 GB index opens instantly. The section CRCs are
7//! checked only by [`BlindIndex::validate`] (`arcsec catalog verify`); lookups
8//! bounds-check every star reference they follow, so a corrupt body can make a solve
9//! fail but cannot make it read out of bounds.
10//!
11//! ```text
12//! header, 256 bytes
13//!   0  magic        [u8; 8]  "ARCSECIX"
14//!   8  version      u32      1
15//!  12  byte order   u32      0x0A0B0C0D as written (reads 0x0D0C0B0A if swapped)
16//!  16  header_len   u32      256
17//!  20  bins         u32      descriptor bins per dimension (128)
18//!  24  n_tiers      u32
19//!  28  star_bands   u32      declination bands in the star directory
20//!  32  n_stars      u64
21//!  40  n_patterns   u64
22//!  48  built_unix   i64
23//!  56  source       [u8; 16] database the stars came from, NUL-padded ("d80")
24//!  72  sections     5 × { offset u64, length u64, crc32 u32, reserved u32 }
25//!                   tiers, stars, star directory, keys, quads
26//! 192  source hash  u64      fingerprint of the source database's files (see
27//!                            [`SourceStamp`]); 0 if not recorded
28//! 200  source bytes u64      total size of those files
29//! 208  source files u32      how many there were; 0 = no stamp recorded
30//! 212  reserved     zero
31//! 252  header crc32 over bytes 0..252
32//!
33//! tiers        n_tiers × 40 bytes, widest first:
34//!                radius f64 (rad), mag_cap f32, members u32,
35//!                first_pattern u64, n_patterns u64, n_anchors u64
36//! stars        n_stars × 12 bytes, sorted by (declination band, RA):
37//!                ra f32 (rad), dec f32 (rad), mag i16 (×100), tier u8, 0 u8
38//! star dir     (star_bands + 1) × u32: first star of each declination band
39//! keys         n_patterns × u64, sorted ascending within each tier
40//! quads        n_patterns × 4 × u32: star indices in canonical vertex order
41//! ```
42//!
43//! A tier's patterns are the contiguous range `first_pattern .. +n_patterns` of
44//! both `keys` and `quads`.
45//!
46//! The source stamp (bytes 192–211) was added after the first release of version 1,
47//! in space that was reserved and written as zero, so files without it are still
48//! version 1 and read as "not recorded"; older readers ignore it.
49
50use core::ops::Range;
51use std::fs::File;
52use std::io::{self, BufWriter, Seek as _, Write};
53use std::path::Path;
54
55use memmap2::Mmap;
56
57use crate::error::{ArcsecError, Result};
58
59/// File magic.
60pub const MAGIC: &[u8; 8] = b"ARCSECIX";
61/// Format version this code reads and writes.
62pub const VERSION: u32 = 1;
63/// Conventional file name in the catalogue directory: `<db>.arcsecix`.
64pub const EXTENSION: &str = "arcsecix";
65
66const HEADER_LEN: usize = 256;
67const BYTE_ORDER: u32 = 0x0A0B_0C0D;
68const N_SECTIONS: usize = 5;
69const SECTIONS_AT: usize = 72;
70const HEADER_CRC_AT: usize = 252;
71const STAMP_AT: usize = 192;
72const TIER_LEN: usize = 40;
73const STAR_LEN: usize = 12;
74const QUAD_LEN: usize = 16;
75
76/// One scale tier of the index.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct TierInfo {
79    /// Disc radius, radians: patterns are drawn from stars within this of an anchor,
80    /// so no pattern is wider than twice it.
81    pub radius: f64,
82    /// Faintest magnitude considered for this tier.
83    pub mag_cap: f32,
84    /// Stars per anchor group (anchor included).
85    pub members: u32,
86    /// First pattern of the tier in the key and quad arrays.
87    pub first_pattern: u64,
88    /// Patterns in the tier.
89    pub n_patterns: u64,
90    /// Anchors (groups) that produced patterns.
91    pub n_anchors: u64,
92}
93
94impl TierInfo {
95    /// The tier's pattern range.
96    #[must_use]
97    pub fn patterns(&self) -> Range<usize> {
98        self.first_pattern as usize..(self.first_pattern + self.n_patterns) as usize
99    }
100}
101
102/// A star of the index.
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct IndexStar {
105    /// Right ascension, radians.
106    pub ra: f32,
107    /// Declination, radians.
108    pub dec: f32,
109    /// Magnitude × 100.
110    pub mag: i16,
111    /// The widest tier any pattern using this star belongs to.
112    pub tier: u8,
113}
114
115/// Which copy of a star database an index was built from, so a later check can tell
116/// whether the database has changed since (a new ASTAP release, a re-download, a
117/// different directory's files).
118///
119/// The fingerprint covers each database file's name, size and first
120/// [`SourceStamp::HEAD_BYTES`] bytes, not its modification time: copying a database
121/// to another disk changes every mtime without changing a star. All zero when not
122/// recorded (indexes built before the stamp existed).
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
124pub struct SourceStamp {
125    /// Number of database files.
126    pub files: u32,
127    /// Their total size in bytes.
128    pub bytes: u64,
129    /// FNV-1a over every file's name, size and head, in name order.
130    pub hash: u64,
131}
132
133impl SourceStamp {
134    /// Bytes of each file's head folded into the hash.
135    pub const HEAD_BYTES: usize = 4096;
136
137    /// Whether the index recorded its source at all.
138    #[must_use]
139    pub fn is_recorded(&self) -> bool {
140        self.files > 0
141    }
142
143    /// Fingerprint the files of database `db_name` in `db_path`: every
144    /// `<db_name>_*.{1476,290,001}`. Reads a few kilobytes per file.
145    ///
146    /// # Errors
147    ///
148    /// [`ArcsecError::CatalogIo`] if the directory or a file cannot be read.
149    pub fn of_database(db_path: &Path, db_name: &str) -> Result<Self> {
150        use std::io::Read as _;
151        let prefix = format!("{db_name}_");
152        let mut names: Vec<String> = std::fs::read_dir(db_path)
153            .map_err(ArcsecError::CatalogIo)?
154            .filter_map(core::result::Result::ok)
155            .filter_map(|e| e.file_name().into_string().ok())
156            .filter(|n| {
157                n.starts_with(&prefix) && [".1476", ".290", ".001"].iter().any(|x| n.ends_with(x))
158            })
159            .collect();
160        names.sort();
161        let mut st = Self::default();
162        let mut h = Fnv::new();
163        let mut buf = vec![0u8; Self::HEAD_BYTES];
164        for n in &names {
165            let mut f = File::open(db_path.join(n)).map_err(ArcsecError::CatalogIo)?;
166            let len = f.metadata().map_err(ArcsecError::CatalogIo)?.len();
167            let mut got = 0;
168            while got < buf.len() {
169                match f.read(&mut buf[got..]) {
170                    Ok(0) => break,
171                    Ok(k) => got += k,
172                    Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
173                    Err(e) => return Err(ArcsecError::CatalogIo(e)),
174                }
175            }
176            h.update(n.as_bytes());
177            h.update(&[0]);
178            h.update(&len.to_le_bytes());
179            h.update(&buf[..got]);
180            st.files += 1;
181            st.bytes += len;
182        }
183        st.hash = h.0;
184        Ok(st)
185    }
186}
187
188/// 64-bit FNV-1a: small, dependency-free, and stable across platforms and releases,
189/// which is all a fingerprint stored in a file needs.
190struct Fnv(u64);
191
192impl Fnv {
193    fn new() -> Self {
194        Self(0xCBF2_9CE4_8422_2325)
195    }
196    fn update(&mut self, bytes: &[u8]) {
197        for &b in bytes {
198            self.0 ^= u64::from(b);
199            self.0 = self.0.wrapping_mul(0x0100_0000_01B3);
200        }
201    }
202}
203
204/// An index assembled in memory, ready to write (the builder's output).
205#[derive(Debug, Default)]
206pub struct BuiltIndex {
207    /// Tiers, widest first.
208    pub tiers: Vec<TierInfo>,
209    /// Stars, sorted by declination band then RA (see [`star_band`]).
210    pub stars: Vec<IndexStar>,
211    /// First star of each declination band, `star_bands + 1` entries.
212    pub star_dir: Vec<u32>,
213    /// Pattern keys, sorted within each tier.
214    pub keys: Vec<u64>,
215    /// Pattern stars, parallel to `keys`.
216    pub quads: Vec<[u32; 4]>,
217    /// Source database name.
218    pub source: String,
219    /// Fingerprint of the source database's files.
220    pub source_stamp: SourceStamp,
221}
222
223/// Declination bands in the star directory: quarter-degree strips.
224pub const STAR_BANDS: u32 = 720;
225
226/// The star-directory band holding declination `dec` (radians).
227#[must_use]
228pub fn star_band(dec: f64) -> u32 {
229    let b = ((dec + core::f64::consts::FRAC_PI_2) / core::f64::consts::PI * f64::from(STAR_BANDS))
230        .floor();
231    (b.max(0.0) as u32).min(STAR_BANDS - 1)
232}
233
234// ── CRC-32 (IEEE 802.3, as zip and PNG use) ────────────────────────────────────
235
236const fn crc_table() -> [u32; 256] {
237    let mut t = [0u32; 256];
238    let mut i = 0;
239    while i < 256 {
240        let mut c = i as u32;
241        let mut k = 0;
242        while k < 8 {
243            c = if c & 1 != 0 {
244                0xEDB8_8320 ^ (c >> 1)
245            } else {
246                c >> 1
247            };
248            k += 1;
249        }
250        t[i] = c;
251        i += 1;
252    }
253    t
254}
255
256static CRC_TABLE: [u32; 256] = crc_table();
257
258/// Incremental CRC-32.
259#[derive(Clone, Copy)]
260struct Crc(u32);
261
262impl Crc {
263    fn new() -> Self {
264        Self(0xFFFF_FFFF)
265    }
266    fn update(&mut self, bytes: &[u8]) {
267        let mut c = self.0;
268        for &b in bytes {
269            c = CRC_TABLE[((c ^ u32::from(b)) & 0xFF) as usize] ^ (c >> 8);
270        }
271        self.0 = c;
272    }
273    fn finish(self) -> u32 {
274        !self.0
275    }
276}
277
278fn crc32(bytes: &[u8]) -> u32 {
279    let mut c = Crc::new();
280    c.update(bytes);
281    c.finish()
282}
283
284// ── Writing ────────────────────────────────────────────────────────────────────
285
286/// A writer that tracks its offset and the CRC of the current section.
287struct SectionWriter<W: Write> {
288    w: W,
289    pos: u64,
290    crc: Crc,
291}
292
293impl<W: Write> SectionWriter<W> {
294    fn put(&mut self, b: &[u8]) -> io::Result<()> {
295        self.w.write_all(b)?;
296        self.crc.update(b);
297        self.pos += b.len() as u64;
298        Ok(())
299    }
300    /// Start a section: pad to 8 bytes (padding is outside every section's CRC).
301    fn begin(&mut self) -> io::Result<u64> {
302        let pad = (8 - self.pos % 8) % 8;
303        self.w.write_all(&[0u8; 8][..pad as usize])?;
304        self.pos += pad;
305        self.crc = Crc::new();
306        Ok(self.pos)
307    }
308}
309
310impl BuiltIndex {
311    /// Total bytes the file will occupy (to within section padding).
312    #[must_use]
313    pub fn file_size(&self) -> u64 {
314        (HEADER_LEN
315            + self.tiers.len() * TIER_LEN
316            + self.stars.len() * STAR_LEN
317            + self.star_dir.len() * 4
318            + self.keys.len() * 8
319            + self.quads.len() * QUAD_LEN
320            + 32) as u64
321    }
322
323    /// Write the index to `path`, via a temporary file renamed into place, so a
324    /// reader never sees a half-written index.
325    ///
326    /// # Errors
327    ///
328    /// [`ArcsecError::CatalogIo`] if the file cannot be written.
329    pub fn write(&self, path: &Path) -> Result<()> {
330        let tmp = path.with_extension(format!("{EXTENSION}.part"));
331        self.write_to(&tmp).map_err(ArcsecError::CatalogIo)?;
332        std::fs::rename(&tmp, path).map_err(ArcsecError::CatalogIo)
333    }
334
335    fn write_to(&self, path: &Path) -> io::Result<()> {
336        let f = File::create(path)?;
337        let mut sw = SectionWriter {
338            w: BufWriter::with_capacity(1 << 20, f),
339            pos: 0,
340            crc: Crc::new(),
341        };
342        // Placeholder header; rewritten at the end with the section table.
343        sw.put(&[0u8; HEADER_LEN])?;
344        let mut sections = [(0u64, 0u64, 0u32); N_SECTIONS];
345
346        let off = sw.begin()?;
347        for t in &self.tiers {
348            sw.put(&t.radius.to_le_bytes())?;
349            sw.put(&t.mag_cap.to_le_bytes())?;
350            sw.put(&t.members.to_le_bytes())?;
351            sw.put(&t.first_pattern.to_le_bytes())?;
352            sw.put(&t.n_patterns.to_le_bytes())?;
353            sw.put(&t.n_anchors.to_le_bytes())?;
354        }
355        sections[0] = (off, sw.pos - off, sw.crc.finish());
356
357        let off = sw.begin()?;
358        for s in &self.stars {
359            let mut r = [0u8; STAR_LEN];
360            r[0..4].copy_from_slice(&s.ra.to_le_bytes());
361            r[4..8].copy_from_slice(&s.dec.to_le_bytes());
362            r[8..10].copy_from_slice(&s.mag.to_le_bytes());
363            r[10] = s.tier;
364            sw.put(&r)?;
365        }
366        sections[1] = (off, sw.pos - off, sw.crc.finish());
367
368        let off = sw.begin()?;
369        for &d in &self.star_dir {
370            sw.put(&d.to_le_bytes())?;
371        }
372        sections[2] = (off, sw.pos - off, sw.crc.finish());
373
374        let off = sw.begin()?;
375        for &k in &self.keys {
376            sw.put(&k.to_le_bytes())?;
377        }
378        sections[3] = (off, sw.pos - off, sw.crc.finish());
379
380        let off = sw.begin()?;
381        for q in &self.quads {
382            let mut r = [0u8; QUAD_LEN];
383            for (i, &s) in q.iter().enumerate() {
384                r[i * 4..i * 4 + 4].copy_from_slice(&s.to_le_bytes());
385            }
386            sw.put(&r)?;
387        }
388        sections[4] = (off, sw.pos - off, sw.crc.finish());
389
390        let mut h = [0u8; HEADER_LEN];
391        h[0..8].copy_from_slice(MAGIC);
392        h[8..12].copy_from_slice(&VERSION.to_le_bytes());
393        h[12..16].copy_from_slice(&BYTE_ORDER.to_le_bytes());
394        h[16..20].copy_from_slice(&(HEADER_LEN as u32).to_le_bytes());
395        h[20..24].copy_from_slice(&(super::pattern::BINS as u32).to_le_bytes());
396        h[24..28].copy_from_slice(&(self.tiers.len() as u32).to_le_bytes());
397        h[28..32].copy_from_slice(&STAR_BANDS.to_le_bytes());
398        h[32..40].copy_from_slice(&(self.stars.len() as u64).to_le_bytes());
399        h[40..48].copy_from_slice(&(self.keys.len() as u64).to_le_bytes());
400        let now = std::time::SystemTime::now()
401            .duration_since(std::time::UNIX_EPOCH)
402            .map_or(0, |d| d.as_secs() as i64);
403        h[48..56].copy_from_slice(&now.to_le_bytes());
404        let src = self.source.as_bytes();
405        let n = src.len().min(16);
406        h[56..56 + n].copy_from_slice(&src[..n]);
407        h[STAMP_AT..STAMP_AT + 8].copy_from_slice(&self.source_stamp.hash.to_le_bytes());
408        h[STAMP_AT + 8..STAMP_AT + 16].copy_from_slice(&self.source_stamp.bytes.to_le_bytes());
409        h[STAMP_AT + 16..STAMP_AT + 20].copy_from_slice(&self.source_stamp.files.to_le_bytes());
410        for (i, (o, l, c)) in sections.iter().enumerate() {
411            let at = SECTIONS_AT + i * 24;
412            h[at..at + 8].copy_from_slice(&o.to_le_bytes());
413            h[at + 8..at + 16].copy_from_slice(&l.to_le_bytes());
414            h[at + 16..at + 20].copy_from_slice(&c.to_le_bytes());
415        }
416        let crc = crc32(&h[..HEADER_CRC_AT]);
417        h[HEADER_CRC_AT..].copy_from_slice(&crc.to_le_bytes());
418
419        let mut f = sw.w.into_inner().map_err(io::IntoInnerError::into_error)?;
420
421        f.seek(io::SeekFrom::Start(0))?;
422        f.write_all(&h)?;
423        f.sync_all()
424    }
425}
426
427// ── Reading ────────────────────────────────────────────────────────────────────
428
429#[inline]
430fn u32_at(b: &[u8], at: usize) -> u32 {
431    u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
432}
433
434#[inline]
435fn u64_at(b: &[u8], at: usize) -> u64 {
436    let mut x = [0u8; 8];
437    x.copy_from_slice(&b[at..at + 8]);
438    u64::from_le_bytes(x)
439}
440
441#[inline]
442fn f32_at(b: &[u8], at: usize) -> f32 {
443    f32::from_bits(u32_at(b, at))
444}
445
446fn invalid(path: &Path, why: impl core::fmt::Display) -> ArcsecError {
447    ArcsecError::CatalogIo(io::Error::new(
448        io::ErrorKind::InvalidData,
449        format!("{}: not a usable arcsec blind index: {why}", path.display()),
450    ))
451}
452
453/// A memory-mapped blind index.
454pub struct BlindIndex {
455    map: Mmap,
456    tiers: Vec<TierInfo>,
457    sections: [(usize, usize, u32); N_SECTIONS],
458    n_stars: usize,
459    n_patterns: usize,
460    star_bands: u32,
461    source: String,
462    source_stamp: SourceStamp,
463    built_unix: i64,
464}
465
466impl core::fmt::Debug for BlindIndex {
467    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
468        f.debug_struct("BlindIndex")
469            .field("source", &self.source)
470            .field("n_stars", &self.n_stars)
471            .field("n_patterns", &self.n_patterns)
472            .field("tiers", &self.tiers)
473            .finish_non_exhaustive()
474    }
475}
476
477/// Whether `path` starts with the index magic. Reads eight bytes.
478#[must_use]
479pub fn is_blind_index(path: &Path) -> bool {
480    use std::io::Read as _;
481    let mut m = [0u8; 8];
482    File::open(path)
483        .and_then(|mut f| f.read_exact(&mut m))
484        .is_ok()
485        && &m == MAGIC
486}
487
488impl BlindIndex {
489    /// Map and check an index file. Cheap whatever the file's size: see the module
490    /// documentation for what is and is not checked here.
491    ///
492    /// # Errors
493    ///
494    /// [`ArcsecError::CatalogIo`] if the file cannot be opened, is not an index, is
495    /// another version or byte order, or its header is inconsistent.
496    pub fn open(path: &Path) -> Result<Self> {
497        let file = File::open(path).map_err(ArcsecError::CatalogIo)?;
498        // Safety: read-only mapping. As with the star databases, the file is not
499        // expected to change while a solve is reading it; the writer replaces it by
500        // rename, which leaves an existing mapping intact.
501        let map = unsafe { Mmap::map(&file) }.map_err(ArcsecError::CatalogIo)?;
502        if map.len() < HEADER_LEN || &map[..8] != MAGIC {
503            return Err(invalid(path, "bad magic"));
504        }
505        let version = u32_at(&map, 8);
506        if version != VERSION {
507            return Err(invalid(
508                path,
509                format!("format version {version}, this build reads {VERSION} - rebuild it"),
510            ));
511        }
512        if u32_at(&map, 12) != BYTE_ORDER {
513            return Err(invalid(path, "written with the other byte order"));
514        }
515        if crc32(&map[..HEADER_CRC_AT]) != u32_at(&map, HEADER_CRC_AT) {
516            return Err(invalid(path, "header checksum mismatch"));
517        }
518        if u32_at(&map, 16) as usize != HEADER_LEN
519            || f64::from(u32_at(&map, 20)) != super::pattern::BINS
520        {
521            return Err(invalid(
522                path,
523                "unsupported header length or descriptor bins",
524            ));
525        }
526        let n_tiers = u32_at(&map, 24) as usize;
527        let star_bands = u32_at(&map, 28);
528        let n_stars = usize::try_from(u64_at(&map, 32)).map_err(|_| invalid(path, "too large"))?;
529        let n_patterns =
530            usize::try_from(u64_at(&map, 40)).map_err(|_| invalid(path, "too large"))?;
531        let built_unix = u64_at(&map, 48) as i64;
532        let source = String::from_utf8_lossy(&map[56..72])
533            .trim_end_matches('\0')
534            .to_string();
535        let source_stamp = SourceStamp {
536            hash: u64_at(&map, STAMP_AT),
537            bytes: u64_at(&map, STAMP_AT + 8),
538            files: u32_at(&map, STAMP_AT + 16),
539        };
540
541        let mut sections = [(0usize, 0usize, 0u32); N_SECTIONS];
542        let expected = [
543            n_tiers.checked_mul(TIER_LEN),
544            n_stars.checked_mul(STAR_LEN),
545            (star_bands as usize + 1).checked_mul(4),
546            n_patterns.checked_mul(8),
547            n_patterns.checked_mul(QUAD_LEN),
548        ];
549        for (i, s) in sections.iter_mut().enumerate() {
550            let at = SECTIONS_AT + i * 24;
551            let off = usize::try_from(u64_at(&map, at)).map_err(|_| invalid(path, "too large"))?;
552            let len =
553                usize::try_from(u64_at(&map, at + 8)).map_err(|_| invalid(path, "too large"))?;
554            if Some(len) != expected[i] || off.checked_add(len).is_none_or(|e| e > map.len()) {
555                return Err(invalid(
556                    path,
557                    format!("section {i} is truncated or mis-sized"),
558                ));
559            }
560            *s = (off, len, u32_at(&map, at + 16));
561        }
562
563        let tb = &map[sections[0].0..sections[0].0 + sections[0].1];
564        let mut tiers = Vec::with_capacity(n_tiers);
565        for t in 0..n_tiers {
566            let r = &tb[t * TIER_LEN..(t + 1) * TIER_LEN];
567            let tier = TierInfo {
568                radius: f64::from_bits(u64_at(r, 0)),
569                mag_cap: f32_at(r, 8),
570                members: u32_at(r, 12),
571                first_pattern: u64_at(r, 16),
572                n_patterns: u64_at(r, 24),
573                n_anchors: u64_at(r, 32),
574            };
575            let end = tier.first_pattern.checked_add(tier.n_patterns);
576            if !(tier.radius.is_finite() && tier.radius > 0.0)
577                || end.is_none_or(|e| e > n_patterns as u64)
578            {
579                return Err(invalid(path, format!("tier {t} is inconsistent")));
580            }
581            tiers.push(tier);
582        }
583
584        Ok(Self {
585            map,
586            tiers,
587            sections,
588            n_stars,
589            n_patterns,
590            star_bands,
591            source,
592            source_stamp,
593            built_unix,
594        })
595    }
596
597    /// Recompute every section checksum: a full read of the file.
598    ///
599    /// # Errors
600    ///
601    /// [`ArcsecError::CatalogIo`] naming the first section whose checksum differs.
602    pub fn validate(&self) -> Result<()> {
603        const NAMES: [&str; N_SECTIONS] = ["tiers", "stars", "star directory", "keys", "quads"];
604        for (i, &(off, len, crc)) in self.sections.iter().enumerate() {
605            if crc32(&self.map[off..off + len]) != crc {
606                return Err(ArcsecError::CatalogIo(io::Error::new(
607                    io::ErrorKind::InvalidData,
608                    format!("blind index: {} section checksum mismatch", NAMES[i]),
609                )));
610            }
611        }
612        Ok(())
613    }
614
615    /// Tiers, widest first.
616    #[must_use]
617    pub fn tiers(&self) -> &[TierInfo] {
618        &self.tiers
619    }
620
621    /// Number of stars.
622    #[must_use]
623    pub fn n_stars(&self) -> usize {
624        self.n_stars
625    }
626
627    /// Number of patterns, all tiers.
628    #[must_use]
629    pub fn n_patterns(&self) -> usize {
630        self.n_patterns
631    }
632
633    /// Database the index was built from.
634    #[must_use]
635    pub fn source(&self) -> &str {
636        &self.source
637    }
638
639    /// Fingerprint of the database the index was built from; not recorded
640    /// ([`SourceStamp::is_recorded`] false) in indexes built before it existed.
641    #[must_use]
642    pub fn source_stamp(&self) -> SourceStamp {
643        self.source_stamp
644    }
645
646    /// Build time, seconds since the Unix epoch.
647    #[must_use]
648    pub fn built_unix(&self) -> i64 {
649        self.built_unix
650    }
651
652    /// File size in bytes.
653    #[must_use]
654    pub fn file_size(&self) -> usize {
655        self.map.len()
656    }
657
658    /// Bytes of the file belonging to one tier's patterns (keys and quads).
659    #[must_use]
660    pub fn tier_bytes(&self, t: &TierInfo) -> u64 {
661        t.n_patterns * (8 + QUAD_LEN as u64)
662    }
663
664    #[inline]
665    fn key(&self, i: usize) -> u64 {
666        u64_at(&self.map, self.sections[3].0 + i * 8)
667    }
668
669    /// The patterns of `tier` whose key is exactly `key`.
670    #[must_use]
671    pub fn lookup(&self, tier: &TierInfo, key: u64) -> Range<usize> {
672        let r = tier.patterns();
673        let (mut lo, mut hi) = (r.start, r.end);
674        while lo < hi {
675            let mid = lo + (hi - lo) / 2;
676            if self.key(mid) < key {
677                lo = mid + 1;
678            } else {
679                hi = mid;
680            }
681        }
682        let start = lo;
683        let mut hi = r.end;
684        while lo < hi {
685            let mid = lo + (hi - lo) / 2;
686            if self.key(mid) <= key {
687                lo = mid + 1;
688            } else {
689                hi = mid;
690            }
691        }
692        start..lo
693    }
694
695    /// The four stars of pattern `i` in canonical order; `None` if the pattern
696    /// index or any star index is out of range (a corrupt file).
697    #[must_use]
698    pub fn quad(&self, i: usize) -> Option<[IndexStar; 4]> {
699        if i >= self.n_patterns {
700            return None;
701        }
702        let at = self.sections[4].0 + i * QUAD_LEN;
703        let mut out = [IndexStar {
704            ra: 0.0,
705            dec: 0.0,
706            mag: 0,
707            tier: 0,
708        }; 4];
709        for (k, s) in out.iter_mut().enumerate() {
710            *s = self.star(u32_at(&self.map, at + k * 4) as usize)?;
711        }
712        Some(out)
713    }
714
715    /// Star `i`; `None` if out of range.
716    #[must_use]
717    pub fn star(&self, i: usize) -> Option<IndexStar> {
718        if i >= self.n_stars {
719            return None;
720        }
721        let at = self.sections[1].0 + i * STAR_LEN;
722        let b = &self.map[at..at + STAR_LEN];
723        Some(IndexStar {
724            ra: f32_at(b, 0),
725            dec: f32_at(b, 4),
726            mag: i16::from_le_bytes([b[8], b[9]]),
727            tier: b[10],
728        })
729    }
730
731    /// Call `f` for every star within `radius` (radians) of (`ra`, `dec`), and a few
732    /// just outside it: candidates come from whole directory bands and a RA window
733    /// widened for the band's declination, and the caller does the exact test.
734    pub fn stars_near(&self, ra: f64, dec: f64, radius: f64, mut f: impl FnMut(&IndexStar)) {
735        use core::f64::consts::{FRAC_PI_2, PI};
736        if self.star_bands != STAR_BANDS {
737            return;
738        }
739        let lo_b = star_band((dec - radius).max(-FRAC_PI_2));
740        let hi_b = star_band((dec + radius).min(FRAC_PI_2));
741        let dir_at = self.sections[2].0;
742        for band in lo_b..=hi_b {
743            let s0 = u32_at(&self.map, dir_at + band as usize * 4) as usize;
744            let s1 =
745                (u32_at(&self.map, dir_at + (band as usize + 1) * 4) as usize).min(self.n_stars);
746            if s0 >= s1 {
747                continue;
748            }
749            // The band's worst-case cos(dec), for the RA half-width.
750            let b_lo = f64::from(band) / f64::from(STAR_BANDS) * PI - FRAC_PI_2;
751            let b_hi = b_lo + PI / f64::from(STAR_BANDS);
752            let cos_min = b_lo.cos().min(b_hi.cos()).max(0.0);
753            let half = if cos_min * PI <= radius || dec.abs() + radius >= FRAC_PI_2 {
754                PI
755            } else {
756                (radius / cos_min).min(PI)
757            };
758            if half >= PI {
759                for i in s0..s1 {
760                    if let Some(s) = self.star(i) {
761                        f(&s);
762                    }
763                }
764                continue;
765            }
766            let ra0 = (ra - half).rem_euclid(2.0 * PI);
767            let ra1 = (ra + half).rem_euclid(2.0 * PI);
768            let windows: &[(f64, f64)] = if ra0 <= ra1 {
769                &[(ra0, ra1)]
770            } else {
771                &[(ra0, 2.0 * PI), (0.0, ra1)]
772            };
773            for &(w0, w1) in windows {
774                // Binary search for the first star with RA >= w0.
775                let (mut lo, mut hi) = (s0, s1);
776                while lo < hi {
777                    let mid = lo + (hi - lo) / 2;
778                    let r = self.star(mid).map_or(f32::INFINITY, |s| s.ra);
779                    if f64::from(r) < w0 {
780                        lo = mid + 1;
781                    } else {
782                        hi = mid;
783                    }
784                }
785                for i in lo..s1 {
786                    let Some(s) = self.star(i) else { break };
787                    if f64::from(s.ra) > w1 {
788                        break;
789                    }
790                    f(&s);
791                }
792            }
793        }
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::test_support::TempDir;
801
802    fn sample() -> BuiltIndex {
803        let stars: Vec<IndexStar> = (0..10)
804            .map(|i| IndexStar {
805                ra: 0.1 * i as f32,
806                dec: 0.2,
807                mag: 1000 + i,
808                tier: 0,
809            })
810            .collect();
811        let mut dir = vec![0u32; STAR_BANDS as usize + 1];
812        let b = star_band(0.2) as usize;
813        for (i, d) in dir.iter_mut().enumerate() {
814            *d = if i <= b { 0 } else { 10 };
815        }
816        BuiltIndex {
817            tiers: vec![TierInfo {
818                radius: 0.01,
819                mag_cap: 12.0,
820                members: 5,
821                first_pattern: 0,
822                n_patterns: 3,
823                n_anchors: 1,
824            }],
825            stars,
826            star_dir: dir,
827            keys: vec![5, 7, 7],
828            quads: vec![[0, 1, 2, 3], [1, 2, 3, 4], [5, 6, 7, 8]],
829            source: "d80".into(),
830            source_stamp: SourceStamp::default(),
831        }
832    }
833
834    #[test]
835    fn round_trips_and_looks_up() {
836        let dir = TempDir::new("arcsecix_rt");
837        let p = dir.path().join("t.arcsecix");
838        sample().write(&p).unwrap();
839        assert!(is_blind_index(&p));
840        let ix = BlindIndex::open(&p).unwrap();
841        ix.validate().unwrap();
842        assert_eq!(ix.source(), "d80");
843        assert_eq!(ix.n_stars(), 10);
844        let t = ix.tiers()[0];
845        assert_eq!(ix.lookup(&t, 7), 1..3);
846        assert_eq!(ix.lookup(&t, 5), 0..1);
847        assert!(ix.lookup(&t, 6).is_empty());
848        assert_eq!(ix.quad(2).unwrap()[3].mag, 1008);
849        let mut n = 0;
850        ix.stars_near(0.3, 0.2, 0.11, |_| n += 1);
851        assert!((3..=5).contains(&n), "{n}");
852    }
853
854    #[test]
855    fn rejects_bad_magic_truncation_and_corruption() {
856        let dir = TempDir::new("arcsecix_bad");
857        let p = dir.path().join("t.arcsecix");
858        sample().write(&p).unwrap();
859        let good = std::fs::read(&p).unwrap();
860
861        let mut b = good.clone();
862        b[0] = b'X';
863        std::fs::write(&p, &b).unwrap();
864        assert!(BlindIndex::open(&p).is_err());
865
866        std::fs::write(&p, &good[..good.len() - 20]).unwrap();
867        assert!(BlindIndex::open(&p).is_err(), "truncated");
868
869        let mut b = good.clone();
870        b[30] ^= 1; // inside the header: header CRC
871        std::fs::write(&p, &b).unwrap();
872        assert!(BlindIndex::open(&p).is_err());
873
874        let mut b = good.clone();
875        let n = b.len();
876        b[n - 3] ^= 0x40; // inside the quads: opens, fails validation
877        std::fs::write(&p, &b).unwrap();
878        let ix = BlindIndex::open(&p).unwrap();
879        assert!(ix.validate().is_err());
880    }
881
882    #[test]
883    fn a_corrupt_star_reference_is_refused_not_followed() {
884        let dir = TempDir::new("arcsecix_ref");
885        let p = dir.path().join("t.arcsecix");
886        let mut s = sample();
887        s.quads[0] = [0, 1, 2, 999];
888        s.write(&p).unwrap();
889        let ix = BlindIndex::open(&p).unwrap();
890        assert!(ix.quad(0).is_none());
891        assert!(ix.quad(99).is_none());
892    }
893
894    #[test]
895    fn the_source_stamp_round_trips_and_an_unstamped_file_reads_as_unrecorded() {
896        let dir = TempDir::new("arcsecix_stamp");
897        let p = dir.path().join("t.arcsecix");
898        sample().write(&p).unwrap();
899        let ix = BlindIndex::open(&p).unwrap();
900        assert!(!ix.source_stamp().is_recorded(), "zeros mean not recorded");
901
902        let mut s = sample();
903        s.source_stamp = SourceStamp {
904            files: 1476,
905            bytes: 1_300_000_000,
906            hash: 0x0123_4567_89AB_CDEF,
907        };
908        s.write(&p).unwrap();
909        let ix = BlindIndex::open(&p).unwrap();
910        ix.validate().unwrap();
911        assert_eq!(ix.source_stamp(), s.source_stamp);
912    }
913
914    #[test]
915    fn the_database_stamp_sees_size_and_content_but_not_other_files() {
916        let dir = TempDir::new("arcsecix_dbstamp");
917        let d = dir.path();
918        std::fs::write(d.join("t_0101.1476"), vec![1u8; 5000]).unwrap();
919        std::fs::write(d.join("t_0201.1476"), vec![2u8; 300]).unwrap();
920        std::fs::write(d.join("u_0101.1476"), b"another database").unwrap();
921        std::fs::write(d.join("t.arcsecix"), b"not a database file").unwrap();
922        let a = SourceStamp::of_database(d, "t").unwrap();
923        assert_eq!((a.files, a.bytes), (2, 5300));
924        assert!(a.is_recorded());
925        assert_eq!(
926            a,
927            SourceStamp::of_database(d, "t").unwrap(),
928            "deterministic"
929        );
930
931        // Unrelated files do not count.
932        std::fs::write(d.join("u_0201.1476"), b"more").unwrap();
933        assert_eq!(a, SourceStamp::of_database(d, "t").unwrap());
934
935        // Same size, different head: a different database.
936        let mut b = vec![1u8; 5000];
937        b[10] = 9;
938        std::fs::write(d.join("t_0101.1476"), &b).unwrap();
939        let c = SourceStamp::of_database(d, "t").unwrap();
940        assert_eq!(c.bytes, a.bytes);
941        assert_ne!(c.hash, a.hash);
942
943        // A missing database: an empty, unrecorded stamp.
944        assert!(!SourceStamp::of_database(d, "zz").unwrap().is_recorded());
945    }
946
947    #[test]
948    fn crc_matches_the_standard_check_value() {
949        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
950    }
951}