Skip to main content

git_vdb/
model.rs

1//! Public request, response, configuration, filtering, and mutation types.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use std::fmt;
6use std::str::FromStr;
7
8/// A JSON object used for point payloads.
9pub type JsonObject = Map<String, Value>;
10
11/// A typed point identifier.
12///
13/// String and unsigned-integer IDs occupy distinct namespaces and have a stable
14/// canonical ordering used to break equal-score query ties.
15#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum PointId {
18    /// A UTF-8 string identifier.
19    String(String),
20    /// An unsigned 64-bit integer identifier.
21    UInt(u64),
22}
23
24impl PointId {
25    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
26        match self {
27            Self::String(value) => {
28                let mut bytes = vec![b's', 0];
29                bytes.extend_from_slice(value.as_bytes());
30                bytes
31            }
32            Self::UInt(value) => {
33                let mut bytes = vec![b'u', 0];
34                bytes.extend_from_slice(&value.to_be_bytes());
35                bytes
36            }
37        }
38    }
39}
40
41impl From<&str> for PointId {
42    fn from(value: &str) -> Self {
43        Self::String(value.to_owned())
44    }
45}
46
47impl From<String> for PointId {
48    fn from(value: String) -> Self {
49        Self::String(value)
50    }
51}
52
53impl From<u64> for PointId {
54    fn from(value: u64) -> Self {
55        Self::UInt(value)
56    }
57}
58
59impl Ord for PointId {
60    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
61        self.canonical_bytes().cmp(&other.canonical_bytes())
62    }
63}
64
65impl PartialOrd for PointId {
66    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
67        Some(self.cmp(other))
68    }
69}
70
71impl fmt::Display for PointId {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::String(value) => write!(f, "{value}"),
75            Self::UInt(value) => write!(f, "{value}"),
76        }
77    }
78}
79
80/// A hexadecimal Git object ID that identifies a tree or commit.
81#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
82#[serde(transparent)]
83pub struct ObjectId(pub String);
84
85impl fmt::Display for ObjectId {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str(&self.0)
88    }
89}
90
91impl FromStr for ObjectId {
92    type Err = git2::Error;
93
94    fn from_str(value: &str) -> Result<Self, Self::Err> {
95        git2::Oid::from_str(value)?;
96        Ok(Self(value.to_owned()))
97    }
98}
99
100impl From<git2::Oid> for ObjectId {
101    fn from(value: git2::Oid) -> Self {
102        Self(value.to_string())
103    }
104}
105
106impl AsRef<str> for ObjectId {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112/// A vector and its optional JSON payload, identified by a typed ID.
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
114pub struct Point {
115    /// The stable typed identifier for this point.
116    pub id: PointId,
117    /// The vector components, whose length must match the collection dimension.
118    pub vector: Vec<f32>,
119    /// Application-defined metadata used by filters and optional result output.
120    #[serde(default)]
121    pub payload: JsonObject,
122}
123
124impl Point {
125    /// Creates a point with an empty payload.
126    pub fn new(id: impl Into<PointId>, vector: impl IntoIterator<Item = f32>) -> Self {
127        Self {
128            id: id.into(),
129            vector: vector.into_iter().collect(),
130            payload: JsonObject::new(),
131        }
132    }
133
134    /// Replaces the point payload and returns the updated point.
135    #[must_use]
136    pub fn with_payload(mut self, payload: JsonObject) -> Self {
137        self.payload = payload;
138        self
139    }
140
141    /// Serializes object-shaped metadata as the point payload.
142    ///
143    /// Arrays, scalars, and null are rejected because persisted point payloads
144    /// are always JSON objects.
145    pub fn with_metadata(mut self, metadata: impl Serialize) -> crate::Result<Self> {
146        match serde_json::to_value(metadata)? {
147            Value::Object(payload) => {
148                self.payload = payload;
149                Ok(self)
150            }
151            _ => Err(crate::Error::Invalid(
152                "point metadata must serialize to a JSON object".into(),
153            )),
154        }
155    }
156}
157
158/// The vector distance used for ranking.
159#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161#[non_exhaustive]
162pub enum Distance {
163    /// Cosine similarity, returned as a descending score.
164    #[default]
165    Cosine,
166}
167
168/// Deterministic approximate-query defaults.
169///
170/// `tables`, `signature_bits`, and `projection_seed` describe the legacy
171/// format-version-1 LSH index. New format-version-2 roots use deterministic
172/// IVF-flat construction and ignore those three compatibility fields.
173#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
174#[serde(default)]
175pub struct IndexConfig {
176    /// Legacy format-version-1 LSH table count.
177    pub tables: usize,
178    /// Legacy format-version-1 projection bits per table signature.
179    pub signature_bits: usize,
180    /// Legacy format-version-1 projection seed.
181    pub projection_seed: u64,
182    /// Point-count threshold at or below which queries default to exact search.
183    pub full_scan_threshold: usize,
184    /// Default number of approximate index partitions to probe.
185    pub default_probes: usize,
186    /// Default maximum number of approximate candidates to discover.
187    pub default_candidate_limit: usize,
188}
189
190impl Default for IndexConfig {
191    fn default() -> Self {
192        Self {
193            tables: 12,
194            signature_bits: 12,
195            projection_seed: 0x6769_742d_7664_6231,
196            full_scan_threshold: 1_000,
197            default_probes: 96,
198            default_candidate_limit: 10_000,
199        }
200    }
201}
202
203/// Collection-wide vector and index configuration.
204#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
205#[serde(default)]
206pub struct CollectionConfig {
207    /// Required number of components in every stored and query vector.
208    pub dimension: usize,
209    /// Distance used to score vectors.
210    pub distance: Distance,
211    /// Optional application-defined vector-space identity checked by queries.
212    pub vector_space: Option<String>,
213    /// Deterministic approximate-index configuration.
214    pub index: IndexConfig,
215}
216
217impl CollectionConfig {
218    /// Creates a cosine collection configuration for the given dimension.
219    pub fn new(dimension: usize) -> Self {
220        Self {
221            dimension,
222            ..Self::default()
223        }
224    }
225
226    /// Assigns an application-defined vector-space identity.
227    #[must_use]
228    pub fn with_vector_space(mut self, vector_space: impl Into<String>) -> Self {
229        self.vector_space = Some(vector_space.into());
230        self
231    }
232
233    /// Replaces deterministic approximate-query defaults.
234    ///
235    /// The legacy LSH construction fields only affect format-version-1 roots.
236    #[must_use]
237    pub fn with_index(mut self, index: IndexConfig) -> Self {
238        self.index = index;
239        self
240    }
241}
242
243impl Default for CollectionConfig {
244    fn default() -> Self {
245        Self {
246            dimension: 0,
247            distance: Distance::Cosine,
248            vector_space: None,
249            index: IndexConfig::default(),
250        }
251    }
252}
253
254/// A JSON scalar value required by a field-match condition.
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct MatchValue {
257    /// The scalar value that must equal the payload field.
258    pub value: Value,
259}
260
261/// Inclusive or exclusive numeric bounds for a payload field.
262#[derive(Clone, Debug, Default, Serialize, Deserialize)]
263pub struct Range {
264    /// Exclusive lower bound.
265    pub gt: Option<f64>,
266    /// Inclusive lower bound.
267    pub gte: Option<f64>,
268    /// Exclusive upper bound.
269    pub lt: Option<f64>,
270    /// Inclusive upper bound.
271    pub lte: Option<f64>,
272}
273
274/// A predicate used inside a [`Filter`].
275#[derive(Clone, Debug, Serialize, Deserialize)]
276#[serde(untagged)]
277#[non_exhaustive]
278pub enum Condition {
279    /// Matches when a dot-separated payload field exists.
280    HasField {
281        /// Dot-separated payload path that must exist.
282        has_field: String,
283    },
284    /// Matches when a scalar payload field is one of the supplied values.
285    FieldIn {
286        /// Dot-separated payload path.
287        key: String,
288        /// Accepted scalar values.
289        any: Vec<Value>,
290    },
291    /// Matches when a scalar payload field is not one of the supplied values.
292    FieldNotIn {
293        /// Dot-separated payload path.
294        key: String,
295        /// Rejected scalar values.
296        none: Vec<Value>,
297    },
298    /// Matches when an array payload field contains the supplied scalar value.
299    FieldContains {
300        /// Dot-separated payload path.
301        key: String,
302        /// Required array member.
303        contains: Value,
304    },
305    /// Matches when the reserved stored document contains a substring.
306    DocumentContains {
307        /// Case-sensitive substring required in the stored document.
308        document_contains: String,
309    },
310    /// Matches when the reserved stored document satisfies a regular expression.
311    DocumentRegex {
312        /// Rust regular expression applied to the stored document.
313        document_regex: String,
314    },
315    /// Matches or range-checks a dot-separated payload field.
316    Field {
317        /// Dot-separated payload path.
318        key: String,
319        /// Optional equality match.
320        #[serde(rename = "match", skip_serializing_if = "Option::is_none")]
321        matches: Option<MatchValue>,
322        /// Optional numeric range.
323        #[serde(skip_serializing_if = "Option::is_none")]
324        range: Option<Range>,
325    },
326    /// Matches points whose typed ID is in the supplied set.
327    HasId {
328        /// Accepted typed IDs.
329        has_id: Vec<PointId>,
330    },
331    /// Evaluates a nested Boolean filter.
332    Nested(Filter),
333}
334
335impl Condition {
336    /// Creates an equality condition for a dot-separated payload path.
337    pub fn matches(key: impl Into<String>, value: impl Into<Value>) -> Self {
338        Self::Field {
339            key: key.into(),
340            matches: Some(MatchValue {
341                value: value.into(),
342            }),
343            range: None,
344        }
345    }
346
347    /// Creates a numeric-range condition for a dot-separated payload path.
348    pub fn range(key: impl Into<String>, range: Range) -> Self {
349        Self::Field {
350            key: key.into(),
351            matches: None,
352            range: Some(range),
353        }
354    }
355
356    /// Creates a condition that accepts the supplied typed point IDs.
357    pub fn has_id(ids: impl IntoIterator<Item = PointId>) -> Self {
358        Self::HasId {
359            has_id: ids.into_iter().collect(),
360        }
361    }
362
363    /// Creates a condition requiring a dot-separated payload path to exist.
364    pub fn exists(key: impl Into<String>) -> Self {
365        Self::HasField {
366            has_field: key.into(),
367        }
368    }
369
370    /// Creates a condition accepting any of a set of scalar field values.
371    pub fn is_in(key: impl Into<String>, values: impl IntoIterator<Item = Value>) -> Self {
372        Self::FieldIn {
373            key: key.into(),
374            any: values.into_iter().collect(),
375        }
376    }
377
378    /// Creates a condition rejecting a set of scalar field values.
379    pub fn not_in(key: impl Into<String>, values: impl IntoIterator<Item = Value>) -> Self {
380        Self::FieldNotIn {
381            key: key.into(),
382            none: values.into_iter().collect(),
383        }
384    }
385
386    /// Creates a condition requiring an array field to contain a scalar value.
387    pub fn contains(key: impl Into<String>, value: impl Into<Value>) -> Self {
388        Self::FieldContains {
389            key: key.into(),
390            contains: value.into(),
391        }
392    }
393
394    /// Creates a case-sensitive substring condition over the stored document.
395    pub fn document_contains(value: impl Into<String>) -> Self {
396        Self::DocumentContains {
397            document_contains: value.into(),
398        }
399    }
400
401    /// Creates a regular-expression condition over the stored document.
402    pub fn document_regex(value: impl Into<String>) -> Self {
403        Self::DocumentRegex {
404            document_regex: value.into(),
405        }
406    }
407}
408
409/// A Boolean point filter.
410///
411/// Every `must` condition and no `must_not` condition must match. When `should`
412/// is non-empty, at least one `should` condition must also match.
413#[derive(Clone, Debug, Default, Serialize, Deserialize)]
414#[serde(default)]
415pub struct Filter {
416    /// Conditions that must all match.
417    pub must: Vec<Condition>,
418    /// Conditions of which at least one must match when the list is non-empty.
419    pub should: Vec<Condition>,
420    /// Conditions that must not match.
421    pub must_not: Vec<Condition>,
422}
423
424impl Filter {
425    /// Creates a filter containing only required conditions.
426    pub fn must(conditions: impl IntoIterator<Item = Condition>) -> Self {
427        Self {
428            must: conditions.into_iter().collect(),
429            ..Self::default()
430        }
431    }
432}
433
434/// Exact or approximate query execution parameters.
435#[derive(Clone, Debug, Default, Serialize, Deserialize)]
436#[serde(default)]
437pub struct QueryParams {
438    /// Explicit mode override; `None` selects exact search for small collections.
439    pub exact: Option<bool>,
440    /// Approximate index partitions to probe, or zero to use the default.
441    pub probes: usize,
442    /// Approximate candidate limit, or zero to use the collection default.
443    pub candidate_limit: usize,
444}
445
446/// A vector similarity query.
447#[derive(Clone, Debug, Serialize, Deserialize)]
448#[serde(default)]
449pub struct Query {
450    /// Query vector, which must match the collection dimension.
451    pub vector: Vec<f32>,
452    /// Maximum number of scored points to return.
453    pub limit: usize,
454    /// Optional point filter applied before final ranking.
455    pub filter: Option<Filter>,
456    /// Whether returned winners include their JSON payloads.
457    pub with_payload: bool,
458    /// Whether returned winners include their stored vectors.
459    pub with_vector: bool,
460    /// Optional vector-space identity that must match collection metadata.
461    pub expected_vector_space: Option<String>,
462    /// Exact or approximate execution controls.
463    pub params: QueryParams,
464}
465
466impl Query {
467    /// Creates a query using collection defaults for exact or approximate mode.
468    pub fn new(vector: impl IntoIterator<Item = f32>, limit: usize) -> Self {
469        Self {
470            vector: vector.into_iter().collect(),
471            limit,
472            ..Self::default()
473        }
474    }
475
476    /// Creates a query that scores every eligible point.
477    pub fn exact(vector: impl IntoIterator<Item = f32>, limit: usize) -> Self {
478        let mut query = Self::new(vector, limit);
479        query.params.exact = Some(true);
480        query
481    }
482
483    /// Creates a query that uses deterministic approximate candidate discovery.
484    pub fn approximate(vector: impl IntoIterator<Item = f32>, limit: usize) -> Self {
485        let mut query = Self::new(vector, limit);
486        query.params.exact = Some(false);
487        query
488    }
489
490    /// Adds a point filter.
491    #[must_use]
492    pub fn with_filter(mut self, filter: Filter) -> Self {
493        self.filter = Some(filter);
494        self
495    }
496
497    /// Requests payloads for returned winners.
498    #[must_use]
499    pub fn with_payload(mut self) -> Self {
500        self.with_payload = true;
501        self
502    }
503
504    /// Requests stored vectors for returned winners.
505    #[must_use]
506    pub fn with_vector(mut self) -> Self {
507        self.with_vector = true;
508        self
509    }
510
511    /// Requires the collection to use the supplied vector-space identity.
512    #[must_use]
513    pub fn in_vector_space(mut self, vector_space: impl Into<String>) -> Self {
514        self.expected_vector_space = Some(vector_space.into());
515        self
516    }
517
518    /// Replaces the exact or approximate execution parameters.
519    #[must_use]
520    pub fn with_params(mut self, params: QueryParams) -> Self {
521        self.params = params;
522        self
523    }
524}
525
526impl Default for Query {
527    fn default() -> Self {
528        Self {
529            vector: Vec::new(),
530            limit: 10,
531            filter: None,
532            with_payload: false,
533            with_vector: false,
534            expected_vector_space: None,
535            params: QueryParams::default(),
536        }
537    }
538}
539
540/// A scored query winner.
541#[derive(Clone, Debug, Serialize, Deserialize)]
542pub struct ScoredPoint {
543    /// Typed point identifier.
544    pub id: PointId,
545    /// Descending cosine similarity score.
546    pub score: f32,
547    /// Payload when requested by [`Query::with_payload`].
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub payload: Option<JsonObject>,
550    /// Stored vector when requested by [`Query::with_vector`].
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub vector: Option<Vec<f32>>,
553}
554
555/// Query algorithm selected after applying collection defaults.
556#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558#[non_exhaustive]
559pub enum QueryMode {
560    /// Every filter-eligible point was scored.
561    Exact,
562    /// Candidates were discovered through the root's deterministic index.
563    Approximate,
564}
565
566/// Work counters for a completed query.
567#[derive(Clone, Debug, Serialize, Deserialize)]
568pub struct QueryStats {
569    /// Algorithm selected for the query.
570    pub mode: QueryMode,
571    /// Total points in the resolved collection root.
572    pub collection_points: usize,
573    /// Index partitions visited; zero for exact search.
574    pub buckets_probed: usize,
575    /// Distinct approximate candidates discovered.
576    pub candidates_discovered: usize,
577    /// Point vectors actually scored.
578    pub vectors_scored: usize,
579    /// Whether approximate discovery consumed its probe budget.
580    pub probe_limit_exhausted: bool,
581    /// Whether approximate discovery consumed its candidate budget.
582    pub candidate_limit_exhausted: bool,
583}
584
585/// Ordered similarity-search results and execution statistics.
586#[derive(Clone, Debug, Serialize, Deserialize)]
587pub struct QueryResult {
588    /// Immutable root that was queried.
589    pub root: ObjectId,
590    /// Winners ordered by descending score and canonical typed ID.
591    pub points: Vec<ScoredPoint>,
592    /// Algorithm and work counters.
593    pub stats: QueryStats,
594}
595
596/// A deterministic point-retrieval request.
597#[derive(Clone, Debug, Default, Serialize, Deserialize)]
598#[serde(default)]
599pub struct GetRequest {
600    /// Optional typed IDs; when combined with a filter, both must match.
601    pub ids: Vec<PointId>,
602    /// Optional payload or ID filter.
603    pub filter: Option<Filter>,
604    /// Number of canonically ordered matches to skip.
605    pub offset: usize,
606    /// Maximum matches to return, or all remaining matches when `None`.
607    pub limit: Option<usize>,
608    /// Whether results include payloads.
609    pub with_payload: bool,
610    /// Whether results include vectors.
611    pub with_vector: bool,
612}
613
614/// A point returned without similarity scoring.
615#[derive(Clone, Debug, Serialize, Deserialize)]
616pub struct Record {
617    /// Typed point identifier.
618    pub id: PointId,
619    /// Payload when requested.
620    #[serde(skip_serializing_if = "Option::is_none")]
621    pub payload: Option<JsonObject>,
622    /// Stored vector when requested.
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub vector: Option<Vec<f32>>,
625}
626
627/// Canonically ordered point-retrieval results.
628#[derive(Clone, Debug, Serialize, Deserialize)]
629pub struct GetResult {
630    /// Immutable root that was read.
631    pub root: ObjectId,
632    /// Matching records after offset and limit are applied.
633    pub points: Vec<Record>,
634}
635
636/// Selects the union of typed IDs and filter matches for deletion.
637#[derive(Clone, Debug, Default, Serialize, Deserialize)]
638#[serde(default)]
639pub struct DeleteSelector {
640    /// Typed IDs to delete; missing IDs are ignored.
641    pub ids: Vec<PointId>,
642    /// Optional filter whose matches are also deleted.
643    pub filter: Option<Filter>,
644}
645
646/// The outcome of a named collection write.
647#[derive(Clone, Debug, Serialize, Deserialize)]
648pub struct WriteResult {
649    /// New deterministic collection root.
650    pub root: ObjectId,
651    /// Number of submitted upserts or points actually removed.
652    pub affected_points: usize,
653}
654
655/// The outcome of one atomic mixed-mutation batch.
656#[derive(Clone, Debug, Serialize, Deserialize)]
657pub struct MutationResult {
658    /// New deterministic collection root.
659    pub root: ObjectId,
660    /// Number of points before the mutation batch.
661    pub points_before: usize,
662    /// Number of points after the mutation batch.
663    pub points_after: usize,
664    /// Number of ordered mutation operations applied.
665    pub operations: usize,
666}
667
668/// A count and the immutable root from which it was read.
669#[derive(Clone, Debug, Serialize, Deserialize)]
670pub struct CountResult {
671    /// Immutable root that was counted.
672    pub root: ObjectId,
673    /// Number of matching points.
674    pub count: usize,
675}
676
677/// Metadata for a named or historical collection view.
678#[derive(Clone, Debug, Serialize, Deserialize)]
679pub struct CollectionInfo {
680    /// Resolved deterministic root.
681    pub root: ObjectId,
682    /// Named collection.
683    pub name: String,
684    /// Persisted format version used by the resolved root.
685    pub format_version: u32,
686    /// Number of points at the resolved root.
687    pub point_count: usize,
688    /// Collection configuration stored in the root.
689    pub config: CollectionConfig,
690    /// Whether the view is historical and rejects writes.
691    pub read_only: bool,
692}
693
694/// Metadata for an immutable root snapshot.
695#[derive(Clone, Debug, Serialize, Deserialize)]
696pub struct SnapshotInfo {
697    /// Deterministic root tree ID.
698    pub root: ObjectId,
699    /// Persisted format version used by the root.
700    pub format_version: u32,
701    /// Number of points in the root.
702    pub point_count: usize,
703    /// Collection configuration stored in the root.
704    pub config: CollectionConfig,
705}
706
707/// One operation in an ordered immutable-root mutation batch.
708#[derive(Clone, Debug, Serialize, Deserialize)]
709#[serde(tag = "operation", rename_all = "snake_case")]
710#[non_exhaustive]
711pub enum SnapshotMutation {
712    /// Adds a new point or replaces an existing point with the same typed ID.
713    Upsert {
714        /// Complete replacement point.
715        point: Point,
716    },
717    /// Deletes the supplied typed IDs.
718    DeleteIds {
719        /// Typed IDs to delete; missing IDs are ignored.
720        ids: Vec<PointId>,
721    },
722    /// Deletes every point matching a filter.
723    DeleteFilter {
724        /// Filter evaluated against the preceding mutation state.
725        filter: Filter,
726    },
727}
728
729impl SnapshotMutation {
730    /// Creates an upsert mutation.
731    pub fn upsert(point: Point) -> Self {
732        Self::Upsert { point }
733    }
734
735    /// Creates a typed-ID deletion mutation.
736    pub fn delete_ids(ids: impl IntoIterator<Item = PointId>) -> Self {
737        Self::DeleteIds {
738            ids: ids.into_iter().collect(),
739        }
740    }
741
742    /// Creates a filter deletion mutation.
743    pub fn delete_filter(filter: Filter) -> Self {
744        Self::DeleteFilter { filter }
745    }
746}
747
748/// One named-collection commit in newest-first history order.
749#[derive(Clone, Debug, Serialize, Deserialize)]
750pub struct HistoryEntry {
751    /// Commit object ID.
752    pub commit: ObjectId,
753    /// Canonical root tree recorded by the commit.
754    pub root: ObjectId,
755    /// First parent commit, when present.
756    pub parent: Option<ObjectId>,
757    /// Commit message generated for the collection operation.
758    pub message: String,
759    /// Commit time in Unix seconds.
760    pub time_seconds: i64,
761}
762
763/// Count and logical size of a set of reachable Git objects.
764#[derive(Clone, Debug, Default, Serialize, Deserialize)]
765pub struct ObjectStats {
766    /// Number of Git objects.
767    pub objects: usize,
768    /// Sum of logical object bytes.
769    pub bytes: usize,
770}
771
772/// Logical point and structural-sharing differences between two roots.
773#[derive(Clone, Debug, Serialize, Deserialize)]
774pub struct DiffResult {
775    /// Resolved left root.
776    pub left_root: ObjectId,
777    /// Resolved right root.
778    pub right_root: ObjectId,
779    /// IDs present only on the right.
780    pub added: Vec<PointId>,
781    /// IDs present only on the left.
782    pub removed: Vec<PointId>,
783    /// IDs present in both roots with changed point content.
784    pub changed: Vec<PointId>,
785    /// Whether collection metadata differs.
786    pub configuration_changed: bool,
787    /// Whether the persisted approximate index differs.
788    pub buckets_changed: bool,
789    /// Objects reachable from both roots.
790    pub shared: ObjectStats,
791    /// Objects reachable only from the left root.
792    pub left_unique: ObjectStats,
793    /// Objects reachable only from the right root.
794    pub right_unique: ObjectStats,
795}
796
797/// Result of basic or full canonical-root validation.
798#[derive(Clone, Debug, Serialize, Deserialize)]
799pub struct ValidationReport {
800    /// Root that was validated.
801    pub root: ObjectId,
802    /// Whether expensive index recomputation was requested.
803    pub full: bool,
804    /// Validated point count.
805    pub point_count: usize,
806    /// Approximate-index partitions checked during full validation.
807    pub checked_buckets: usize,
808    /// `true` when validation completed without finding corruption.
809    pub valid: bool,
810}