Skip to main content

core_storage/
snapshot.rs

1use crate::columns::ColumnStore;
2use crate::edge_props::EdgeProps;
3use crate::idmap::IdMap;
4use crate::interner::Interner;
5use crate::pack::{push_u32, read_u32};
6use crate::topology::Topology;
7use crate::types::{GraphError, Result};
8use crate::v8::encode::V8Meta;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11
12pub const MAGIC: [u8; 4] = *b"GDB1";
13/// V5: uncompressed bincode payload with CRC32 header.
14pub const VERSION_5: u16 = 5;
15/// V6: zstd-compressed V5 payload in a container.
16pub const VERSION_6: u16 = 6;
17/// V7: zstd(crc + packed CSR + packed columns + bincode leftover).
18pub const VERSION_7: u16 = 7;
19/// V8: 4KB header page + rkyv sections (mmap-able zero-copy).
20pub const VERSION_8: u16 = 8;
21/// V9: the V8 container with one shared string table (section 12) instead of a
22/// full copy of the table inside every string column.
23///
24/// The container is unchanged — same `GDB1` magic, same 4 KB header page, same
25/// 16-byte directory entries, same per-section CRC — so both versions decode
26/// through `MappedBase`.  The version still moves, because a V8 reader opening
27/// a V9 snapshot would find every column's own `strings` empty and silently
28/// drop every string property.  Refusing the open with `snapshot: unsupported
29/// version 9` is the point of the bump.
30pub const VERSION_9: u16 = 9;
31/// Current default encoding version.
32pub const VERSION: u16 = VERSION_9;
33
34/// IVF state for one side (src or dst) of a single approximate rule.
35/// Persisted in V4 snapshots so `open()` can restore cluster assignments
36/// without re-fitting k-means.
37#[derive(Serialize, Deserialize, Clone, Default, Debug)]
38pub struct SideIvfState {
39    /// Fitted k-means centroids (empty = not yet fitted).
40    pub centroids: Vec<Vec<f64>>,
41    /// Per-node cluster assignment (node_id → centroid index).
42    pub clusters: BTreeMap<u32, usize>,
43    /// Drift counter at snapshot time.
44    pub drift: u64,
45}
46
47/// IVF state for both sides of one approximate rule.
48#[derive(Serialize, Deserialize, Clone, Default, Debug)]
49pub struct PerRuleIvfState {
50    pub src: SideIvfState,
51    pub dst: SideIvfState,
52}
53
54#[derive(Serialize, Deserialize)]
55pub struct SnapshotState {
56    pub ids: IdMap,
57    pub syms: Interner,
58    pub topo: Topology,
59    pub props: ColumnStore,
60    pub labels: Vec<u32>,
61    pub edge_props: EdgeProps,
62    /// Bincoded `RuleDef` bytes — one entry per rule.  Raw bytes keep
63    /// core-storage independent of core-rules.
64    pub rule_defs: Vec<Vec<u8>>,
65    pub provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
66    /// Per-rule budget-trip flags.
67    pub rule_tripped: BTreeMap<String, bool>,
68    /// Per-rule fire counters.
69    pub rule_fires: BTreeMap<String, u64>,
70    /// Per-approximate-rule IVF state (centroids + assignments + drift).
71    /// Empty for exact rules and rules with no fitted clusters.
72    /// Added in VERSION 4.
73    pub ivf_state: BTreeMap<String, PerRuleIvfState>,
74    /// Bincoded `ViewDef` bytes — one entry per materialized view.  Raw bytes
75    /// keep core-storage independent of core-rules.  Values are NOT stored
76    /// here; they are recomputed after open from the persisted topo + props.
77    /// Added in VERSION 5.
78    pub view_defs: Vec<Vec<u8>>,
79    /// True when the snapshot write truncated the WAL (`keep_wal: false`),
80    /// i.e. the on-disk WAL head coincides with this snapshot's state and
81    /// `open_at` must load the snapshot as its base before replaying frames.
82    /// Serialized in the V7 meta section only; V5/V6 payloads never carried
83    /// it, so it is skipped here to keep their bincode wire shape frozen
84    /// (decode of old formats leaves the default `false` = WAL-only as-of).
85    #[serde(skip)]
86    pub wal_truncated: bool,
87    /// Per-approximate-rule HNSW graph blobs: rule name → `(src_blob, dst_blob)`.
88    /// Each blob is opaque to this crate and carries its own magic and version
89    /// (`MHNS`, v3 as of 0.6.6; a 0.6.5 store's blobs are a bare bincoded
90    /// `HnswIndex` and are read as v1).  Nothing here parses them, which is why
91    /// the blob format can change without a snapshot format bump.  Serialized in the V7 meta
92    /// section only; V5/V6 payloads never carried it — skipped here so their
93    /// bincode wire shape is frozen (missing → default empty map).
94    #[serde(skip)]
95    pub hnsw_state: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
96}
97
98#[derive(Serialize, Deserialize)]
99struct V7Meta {
100    ids: IdMap,
101    syms: Interner,
102    labels: Vec<u32>,
103    edge_props: EdgeProps,
104    rule_defs: Vec<Vec<u8>>,
105    provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
106    rule_tripped: BTreeMap<String, bool>,
107    rule_fires: BTreeMap<String, u64>,
108    ivf_state: BTreeMap<String, PerRuleIvfState>,
109    view_defs: Vec<Vec<u8>>,
110    /// See [`SnapshotState::wal_truncated`]. V7-only field.
111    wal_truncated: bool,
112    /// Per-approximate-rule HNSW graph blobs: rule name → `(src_blob, dst_blob)`.
113    /// Added last so the field is easily skipped on old V7 blobs by setting a
114    /// default.  V7 is unreleased; the fixture is regenerated after this change.
115    #[serde(default)]
116    hnsw: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
117}
118
119fn wrap_zstd(version: u16, inner: Vec<u8>) -> Vec<u8> {
120    let compressed =
121        zstd::encode_all(inner.as_slice(), 3).expect("zstd compress cannot fail on in-memory buf");
122    let mut out = Vec::with_capacity(6 + compressed.len());
123    out.extend(MAGIC);
124    out.extend(version.to_le_bytes());
125    out.extend(compressed);
126    out
127}
128
129fn crc_inner(payload: &[u8]) -> Vec<u8> {
130    let crc = crc32fast::hash(payload);
131    let mut inner = Vec::with_capacity(4 + payload.len());
132    inner.extend(crc.to_le_bytes());
133    inner.extend(payload);
134    inner
135}
136
137/// Encode a snapshot as a V8 container (mmap-able zero-copy).
138///
139/// V8 is the default format.  V7/V6/V5 are still decoded on open.
140///
141/// # Errors
142/// [`GraphError::Corrupt`] if any section exceeds 4 GiB.
143pub fn encode(state: &SnapshotState) -> Result<Vec<u8>> {
144    encode_v8_from_state(state)
145}
146
147fn encode_v8_from_state(state: &SnapshotState) -> Result<Vec<u8>> {
148    let ivf_bytes = if state.ivf_state.is_empty() {
149        Vec::new()
150    } else {
151        bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
152    };
153    let meta = V8Meta {
154        labels: state.labels.clone(),
155        edge_props: state.edge_props.clone(),
156        rule_defs: state.rule_defs.clone(),
157        provenance: state.provenance.clone(),
158        rule_tripped: state.rule_tripped.clone(),
159        rule_fires: state.rule_fires.clone(),
160        ivf_bytes,
161        view_defs: state.view_defs.clone(),
162        wal_truncated: state.wal_truncated,
163        hnsw: state.hnsw_state.clone(),
164        last_change: HashMap::new(),
165    };
166    let mut out = Vec::new();
167    crate::v8::encode::encode_v8(
168        None,
169        None,
170        None,
171        None,
172        None,
173        &state.topo,
174        &state.props,
175        &state.ids,
176        &state.syms,
177        &meta,
178        &mut out,
179    )?;
180    Ok(out)
181}
182
183/// V6 encode kept so tests can pin `decode(v6_bytes)` after VERSION=7.
184pub fn encode_v6(state: &SnapshotState) -> Vec<u8> {
185    let payload = bincode::serialize(state).expect("snapshot serialize cannot fail");
186    wrap_zstd(VERSION_6, crc_inner(&payload))
187}
188
189fn section_len(name: &str, len: usize) -> Result<u32> {
190    u32::try_from(len).map_err(|_| GraphError::Corrupt {
191        detail: format!(
192            "snapshot: packed {name} section is {len} bytes, exceeds u32 length prefix"
193        ),
194    })
195}
196
197pub fn encode_v7(state: &SnapshotState) -> Result<Vec<u8>> {
198    let mut topo = Vec::new();
199    state.topo.pack(&mut topo);
200    let mut props = Vec::new();
201    state.props.pack(&mut props);
202    let meta = V7Meta {
203        ids: state.ids.clone(),
204        syms: state.syms.clone(),
205        labels: state.labels.clone(),
206        edge_props: state.edge_props.clone(),
207        rule_defs: state.rule_defs.clone(),
208        provenance: state.provenance.clone(),
209        rule_tripped: state.rule_tripped.clone(),
210        rule_fires: state.rule_fires.clone(),
211        ivf_state: state.ivf_state.clone(),
212        view_defs: state.view_defs.clone(),
213        wal_truncated: state.wal_truncated,
214        hnsw: state.hnsw_state.clone(),
215    };
216    let meta_bytes = bincode::serialize(&meta).expect("snapshot meta serialize cannot fail");
217    let mut payload = Vec::with_capacity(8 + topo.len() + props.len() + meta_bytes.len());
218    push_u32(&mut payload, section_len("topology", topo.len())?);
219    payload.extend_from_slice(&topo);
220    push_u32(&mut payload, section_len("columns", props.len())?);
221    payload.extend_from_slice(&props);
222    payload.extend_from_slice(&meta_bytes);
223    Ok(wrap_zstd(VERSION_7, crc_inner(&payload)))
224}
225
226/// Peek at the on-disk snapshot format version without a full decode.
227///
228/// Reads only the 6-byte header (4 B magic + 2 B version LE).  Returns
229/// `None` if `bytes` is empty (absent snapshot), or an error if the magic
230/// is wrong or the header is truncated.  Does not validate the payload.
231pub fn peek_version(bytes: &[u8]) -> Result<Option<u16>> {
232    if bytes.is_empty() {
233        return Ok(None);
234    }
235    if bytes.len() < 6 || bytes[0..4] != MAGIC {
236        return Err(crate::types::GraphError::Corrupt {
237            detail: "snapshot: bad magic or truncated header".into(),
238        });
239    }
240    // Infallible: `bytes.len() >= 6` checked above; `bytes[4..6]` is exactly 2 bytes.
241    Ok(Some(u16::from_le_bytes(bytes[4..6].try_into().unwrap())))
242}
243
244pub fn decode(bytes: &[u8]) -> Result<Option<SnapshotState>> {
245    if bytes.is_empty() {
246        return Ok(None);
247    }
248    if bytes.len() < 6 || bytes[0..4] != MAGIC {
249        return Err(GraphError::Corrupt {
250            detail: "snapshot: bad magic".into(),
251        });
252    }
253    // Infallible: `bytes.len() >= 6` checked above; `bytes[4..6]` is exactly 2 bytes.
254    let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
255    match version {
256        VERSION_5 => decode_v5(&bytes[6..]),
257        VERSION_6 => decode_v6(&bytes[6..]),
258        VERSION_7 => decode_v7(&bytes[6..]),
259        // V8 and V9 are the same file-based container; `decode_v8_from_mapped`
260        // reads the shared string section when the directory carries one.
261        VERSION_8 | VERSION_9 => {
262            let mapped = crate::v8::MappedBase::from_bytes(bytes.to_vec())?;
263            decode_v8_from_mapped(&mapped)
264        }
265        other => {
266            let hint = if other == 3 {
267                " — V3 snapshot is no longer supported; re-snapshot with a V8 binary"
268            } else if other == 4 {
269                " — V4 snapshot is no longer supported; re-snapshot with a V8 binary"
270            } else {
271                ""
272            };
273            Err(GraphError::Corrupt {
274                detail: format!("snapshot: unsupported version {other}{hint}"),
275            })
276        }
277    }
278}
279
280/// Reconstruct a full `SnapshotState` from a `MappedBase`.
281///
282/// Used by `decode()` for the fuzz-safe migration/integrity path.  The
283/// hot production path in `db.rs` does NOT call this — it uses zero-copy
284/// seam views backed by `MappedBase::topology()` (unchecked) directly.
285///
286/// This function uses `rkyv::access` (validated) for all large sections — the
287/// shared string table (12) included — so that corrupt bytes return
288/// `GraphError::Corrupt` rather than UB.  Small sections (IDS, SYMS,
289/// RULES_META, VIEWS) already CRC-check on first touch.
290pub fn decode_v8_from_mapped(mapped: &crate::v8::MappedBase) -> Result<Option<SnapshotState>> {
291    use crate::v8::encode::{
292        archived_edge_props_to_owned, archived_hnsw_to_owned, archived_provenance_to_owned,
293        archived_rules_meta_to_owned, archived_to_columnstore, archived_to_idmap,
294        archived_to_interner, archived_views_to_owned, csr_to_topology, decode_ivf_bytes,
295        decode_meta,
296    };
297    use crate::v8::{
298        SECTION_COLUMNS, SECTION_EDGE_PROPS, SECTION_HNSW, SECTION_PROVENANCE, SECTION_STRINGS,
299        SECTION_TOPOLOGY,
300    };
301
302    // Large sections: use validated rkyv::access so corrupt bytes return
303    // GraphError::Corrupt instead of UB (required by the fuzz invariant).
304    // The production seam path (db.rs topo_view/props_view) uses the
305    // unchecked MappedBase::topology()/columns()/edge_props_section() accessors.
306    let archived_topo = rkyv::access::<crate::v8::layout::ArchivedCsrData, rkyv::rancor::Error>(
307        mapped.section_bytes(SECTION_TOPOLOGY)?,
308    )
309    .map_err(|e| GraphError::Corrupt {
310        detail: format!("v8: topology rkyv access: {e}"),
311    })?;
312    let topo = csr_to_topology(archived_topo);
313
314    let archived_cols =
315        rkyv::access::<crate::v8::layout::ArchivedColumnsData, rkyv::rancor::Error>(
316            mapped.section_bytes(SECTION_COLUMNS)?,
317        )
318        .map_err(|e| GraphError::Corrupt {
319            detail: format!("v8: columns rkyv access: {e}"),
320        })?;
321    // Shared string table (section 12).  `None` only when the directory has no
322    // entry — a pre-V9 snapshot, whose columns carry their own tables.  An
323    // unreadable section is an error, never "absent": treating it as absent
324    // would hand back the empty per-column tables a V9 snapshot writes and
325    // silently drop every string property.
326    //
327    // Validated `rkyv::access`, not the `access_unchecked` that
328    // `MappedBase::string_table()` uses: this is the fuzz-safe decode path, and
329    // its contract is that corrupt bytes return `GraphError::Corrupt` rather
330    // than resolving a bad relative pointer into UB.
331    let shared_strings = if mapped.has_section(SECTION_STRINGS) {
332        Some(
333            rkyv::access::<crate::v8::layout::ArchivedStringTableData, rkyv::rancor::Error>(
334                mapped.section_bytes(SECTION_STRINGS)?,
335            )
336            .map_err(|e| GraphError::Corrupt {
337                detail: format!("v8: strings rkyv access: {e}"),
338            })?,
339        )
340    } else {
341        None
342    };
343    let props = archived_to_columnstore(archived_cols, shared_strings);
344
345    let archived_ids = mapped.ids()?;
346    let ids = archived_to_idmap(archived_ids);
347
348    let archived_syms = mapped.syms()?;
349    let syms = archived_to_interner(archived_syms);
350
351    // V8Meta now carries only labels and wal_truncated in the bincode section.
352    // IVF state is read from section 10 directly.
353    let meta_bytes = mapped.meta_bytes()?;
354    let meta = decode_meta(meta_bytes)?;
355
356    let archived_ep =
357        rkyv::access::<crate::v8::layout::ArchivedEdgePropsData, rkyv::rancor::Error>(
358            mapped.section_bytes(SECTION_EDGE_PROPS)?,
359        )
360        .map_err(|e| GraphError::Corrupt {
361            detail: format!("v8: edge_props rkyv access: {e}"),
362        })?;
363    let edge_props = archived_edge_props_to_owned(archived_ep);
364
365    let archived_hnsw = rkyv::access::<
366        crate::v8::layout::ArchivedHnswSectionData,
367        rkyv::rancor::Error,
368    >(mapped.section_bytes(SECTION_HNSW)?)
369    .map_err(|e| GraphError::Corrupt {
370        detail: format!("v8: hnsw rkyv access: {e}"),
371    })?;
372    let hnsw_state = archived_hnsw_to_owned(archived_hnsw);
373
374    let archived_prov = rkyv::access::<
375        crate::v8::layout::ArchivedProvenanceSectionData,
376        rkyv::rancor::Error,
377    >(mapped.section_bytes(SECTION_PROVENANCE)?)
378    .map_err(|e| GraphError::Corrupt {
379        detail: format!("v8: provenance rkyv access: {e}"),
380    })?;
381    let provenance = archived_provenance_to_owned(archived_prov);
382
383    let (rule_defs, rule_tripped, rule_fires) =
384        archived_rules_meta_to_owned(mapped.rules_meta_section()?);
385    let view_defs = archived_views_to_owned(mapped.views_section()?);
386    let ivf_state = decode_ivf_bytes(mapped.ivf_bytes()?);
387
388    Ok(Some(SnapshotState {
389        ids,
390        syms,
391        topo,
392        props,
393        labels: meta.labels,
394        edge_props,
395        rule_defs,
396        provenance,
397        rule_tripped,
398        rule_fires,
399        ivf_state,
400        view_defs,
401        wal_truncated: meta.wal_truncated,
402        hnsw_state,
403    }))
404}
405
406/// Decode a V5 (uncompressed) payload.  The 4-byte header has already been
407/// stripped; `body` starts at the CRC32 field.
408fn decode_v5(body: &[u8]) -> Result<Option<SnapshotState>> {
409    let payload = strip_crc(body)?;
410    bincode::deserialize(payload)
411        .map(Some)
412        .map_err(|e| GraphError::Corrupt {
413            detail: format!("snapshot: {e}"),
414        })
415}
416
417/// Decode a V6 (zstd-compressed) payload.  The 4-byte header has already been
418/// stripped; `body` starts at the compressed bytes.
419fn decode_v6(body: &[u8]) -> Result<Option<SnapshotState>> {
420    let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
421        detail: format!("snapshot: zstd decompress failed: {e}"),
422    })?;
423    decode_v5(&inner)
424}
425
426fn decode_v7(body: &[u8]) -> Result<Option<SnapshotState>> {
427    let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
428        detail: format!("snapshot: zstd decompress failed: {e}"),
429    })?;
430    let payload = strip_crc(&inner)?;
431    let mut pos = 0usize;
432    let topo_len = read_u32(payload, &mut pos)? as usize;
433    let topo_bytes = payload
434        .get(pos..pos + topo_len)
435        .ok_or_else(|| GraphError::Corrupt {
436            detail: "snapshot: truncated packed topology".into(),
437        })?;
438    pos += topo_len;
439    let (topo, topo_consumed) = Topology::unpack(topo_bytes)?;
440    if topo_consumed != topo_len {
441        return Err(GraphError::Corrupt {
442            detail: format!("snapshot: topology pack consumed {topo_consumed} of {topo_len}"),
443        });
444    }
445    let props_len = read_u32(payload, &mut pos)? as usize;
446    let props_bytes = payload
447        .get(pos..pos + props_len)
448        .ok_or_else(|| GraphError::Corrupt {
449            detail: "snapshot: truncated packed columns".into(),
450        })?;
451    pos += props_len;
452    let (props, props_consumed) = ColumnStore::unpack(props_bytes)?;
453    if props_consumed != props_len {
454        return Err(GraphError::Corrupt {
455            detail: format!("snapshot: columns pack consumed {props_consumed} of {props_len}"),
456        });
457    }
458    let meta: V7Meta = bincode::deserialize(&payload[pos..]).map_err(|e| GraphError::Corrupt {
459        detail: format!("snapshot: {e}"),
460    })?;
461    Ok(Some(SnapshotState {
462        ids: meta.ids,
463        syms: meta.syms,
464        topo,
465        props,
466        labels: meta.labels,
467        edge_props: meta.edge_props,
468        rule_defs: meta.rule_defs,
469        provenance: meta.provenance,
470        rule_tripped: meta.rule_tripped,
471        rule_fires: meta.rule_fires,
472        ivf_state: meta.ivf_state,
473        view_defs: meta.view_defs,
474        wal_truncated: meta.wal_truncated,
475        hnsw_state: meta.hnsw,
476    }))
477}
478
479fn strip_crc(body: &[u8]) -> Result<&[u8]> {
480    if body.len() < 4 {
481        return Err(GraphError::Corrupt {
482            detail: "snapshot: truncated CRC header".into(),
483        });
484    }
485    // Infallible: `body.len() >= 4` checked above; `body[0..4]` is exactly 4 bytes.
486    let crc = u32::from_le_bytes(body[0..4].try_into().unwrap());
487    let payload = &body[4..];
488    if crc32fast::hash(payload) != crc {
489        return Err(GraphError::Corrupt {
490            detail: "snapshot: crc mismatch".into(),
491        });
492    }
493    Ok(payload)
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::types::Value;
500
501    fn tiny_state() -> SnapshotState {
502        let mut ids = IdMap::new();
503        ids.get_or_insert("a");
504        ids.get_or_insert("b");
505        let mut syms = Interner::new();
506        let n = syms.intern("N");
507        let e = syms.intern("E");
508        let mut topo = Topology::new();
509        topo.add_edge(e, 0, 1);
510        let mut props = ColumnStore::new();
511        props.set(0, "v", Value::Int(42));
512        props.set(1, "name", Value::Str("bob".into()));
513        SnapshotState {
514            ids,
515            syms,
516            topo,
517            props,
518            labels: vec![n, n],
519            edge_props: EdgeProps::new(),
520            rule_defs: vec![],
521            provenance: BTreeMap::new(),
522            rule_tripped: BTreeMap::new(),
523            rule_fires: BTreeMap::new(),
524            ivf_state: BTreeMap::new(),
525            view_defs: vec![],
526            wal_truncated: true,
527            hnsw_state: BTreeMap::new(),
528        }
529    }
530
531    #[test]
532    fn decode_v6_bytes_still_works_after_version_9_default_encode() {
533        let state = tiny_state();
534        let v6 = encode_v6(&state);
535        assert_eq!(&v6[0..4], MAGIC);
536        assert_eq!(u16::from_le_bytes([v6[4], v6[5]]), VERSION_6);
537        let v9 = encode(&state).unwrap();
538        assert_eq!(u16::from_le_bytes([v9[4], v9[5]]), VERSION_9);
539        assert_eq!(VERSION, VERSION_9);
540
541        let back6 = decode(&v6).unwrap().unwrap();
542        assert!(
543            !back6.wal_truncated,
544            "V6 payload never carried wal_truncated; decode must default false"
545        );
546        assert_eq!(back6.ids.get("a"), Some(0));
547        assert_eq!(back6.props.get(0, "v"), Some(&Value::Int(42)));
548        assert_eq!(back6.topo.edge_count(), 1);
549        assert_eq!(
550            back6
551                .topo
552                .neighbors(
553                    back6.syms.get("E").unwrap(),
554                    crate::topology::Direction::Out,
555                    0
556                )
557                .as_ref(),
558            &[1]
559        );
560
561        let back9 = decode(&v9).unwrap().unwrap();
562        assert!(
563            back9.wal_truncated,
564            "V9 meta must round-trip wal_truncated=true"
565        );
566        assert_eq!(back9.ids.get("b"), Some(1));
567        assert_eq!(
568            back9.props.get(1, "name"),
569            Some(&Value::Str("bob".into())),
570            "a string property must survive the shared table"
571        );
572        assert_eq!(back9.topo.edge_count(), 1);
573        assert_eq!(back9.labels, vec![back9.syms.get("N").unwrap(); 2]);
574    }
575}