Skip to main content

oxilite_core/
stats.rs

1//! Statistics used by the join-order planner.
2//!
3//! Stats are refreshed explicitly (`optimize()`, or after a bulk load) rather than on every
4//! write: on D1 every index entry written is billed, and a per-write counter would be a hot
5//! row. Stale stats only degrade plan quality, never correctness.
6//!
7// @lat: [[architecture#Query planner#Statistics]]
8
9use crate::encoding::rdf_type_id;
10use crate::error::Result;
11use crate::sql::{col, expect_len, Capabilities, Request, Response, Statement};
12use std::collections::{BTreeSet, HashMap};
13
14/// Per-predicate statistics.
15#[derive(Debug, Clone, Copy, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct PredicateStats {
18    pub triples: f64,
19    pub distinct_subjects: f64,
20    pub distinct_objects: f64,
21}
22
23/// Planner statistics loaded from `stats_pred` / `stats_class`.
24#[derive(Debug, Clone, Default)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct Stats {
27    /// `true` when `optimize()` has been run at least once.
28    pub available: bool,
29    pub total: f64,
30    pub predicates: HashMap<i64, PredicateStats>,
31    /// Instance count per `rdf:type` class.
32    pub classes: HashMap<i64, f64>,
33    /// Triples per frequent (predicate, object) pair of low-cardinality predicates: values
34    /// much more common than their predicate's average (skew the average would hide).
35    pub pairs: HashMap<(i64, i64), f64>,
36    pub graph_index: bool,
37    /// The full-text index exists (`oxl:textMatch` compiles to FTS5).
38    pub text_index: bool,
39    /// Transitive properties (from `tbox_closure`, any scope), for query-time reasoning.
40    pub transitive: BTreeSet<i64>,
41    /// Graphs with a closure scope of their own (see `registry::ontology_axioms`).
42    pub schema_scopes: BTreeSet<i64>,
43    /// The versioning level and history of the store (see `version`).
44    pub version: crate::version::VersionState,
45}
46
47fn id_col(caps: &Capabilities, c: &str) -> String {
48    if caps.int64_as_text {
49        format!("CAST({c} AS TEXT)")
50    } else {
51        c.into()
52    }
53}
54
55impl Stats {
56    /// Statements that load statistics and store settings.
57    pub fn load_request(caps: &Capabilities) -> Request {
58        Request::read(vec![
59            Statement::new("SELECT key, value FROM oxilite_meta"),
60            Statement::new(format!(
61                "SELECT {}, triples, distinct_s, distinct_o FROM stats_pred",
62                id_col(caps, "p")
63            )),
64            Statement::new(format!(
65                "SELECT {}, instances FROM stats_class",
66                id_col(caps, "o")
67            )),
68            crate::reason::transitive_statement(|c| id_col(caps, c)),
69            Statement::new(format!(
70                "SELECT {}, {}, n FROM stats_po",
71                id_col(caps, "p"),
72                id_col(caps, "o")
73            )),
74            crate::registry::scopes_statement(|c| id_col(caps, c)),
75        ])
76    }
77
78    pub fn from_response(response: &Response) -> Result<Self> {
79        expect_len(response, 6)?;
80        let mut stats = Self::default();
81        for row in &response[0].rows {
82            let key = col(row, 0)?.as_str().unwrap_or_default();
83            let value = col(row, 1)?.clone().into_string().unwrap_or_default();
84            match key {
85                "graph_index" => stats.graph_index = value == "1",
86                "text_index" => stats.text_index = value == "1",
87                k @ ("versioning" | "history" | "stamp_column" | "stamp_index" | "as_of_index") => {
88                    stats.version.absorb(k, &value)
89                }
90                "total" => {
91                    stats.total = value.parse().unwrap_or(0.0);
92                    stats.available = true;
93                }
94                _ => {}
95            }
96        }
97        for row in &response[1].rows {
98            let (Some(p), Some(t), Some(ds), Some(d_o)) = (
99                col(row, 0)?.as_i64(),
100                col(row, 1)?.as_f64(),
101                col(row, 2)?.as_f64(),
102                col(row, 3)?.as_f64(),
103            ) else {
104                continue;
105            };
106            stats.predicates.insert(
107                p,
108                PredicateStats {
109                    triples: t,
110                    distinct_subjects: ds.max(1.0),
111                    distinct_objects: d_o.max(1.0),
112                },
113            );
114        }
115        for row in &response[2].rows {
116            if let (Some(o), Some(n)) = (col(row, 0)?.as_i64(), col(row, 1)?.as_f64()) {
117                stats.classes.insert(o, n);
118            }
119        }
120        for row in &response[3].rows {
121            if let Some(p) = col(row, 0)?.as_i64() {
122                stats.transitive.insert(p);
123            }
124        }
125        for row in &response[5].rows {
126            if let Some(g) = col(row, 0)?.as_i64() {
127                stats.schema_scopes.insert(g);
128            }
129        }
130        for row in &response[4].rows {
131            if let (Some(p), Some(o), Some(n)) = (
132                col(row, 0)?.as_i64(),
133                col(row, 1)?.as_i64(),
134                col(row, 2)?.as_f64(),
135            ) {
136                stats.pairs.insert((p, o), n);
137            }
138        }
139        Ok(stats)
140    }
141
142    /// Statements recomputing statistics, the schema closure and the shape index (run by
143    /// `optimize()`).
144    pub fn refresh_request() -> Request {
145        let mut r = Self::refresh_statements();
146        r.extend(crate::reason::closure_statements());
147        r.extend(crate::shapes::refresh_statements());
148        Request::atomic(r)
149    }
150
151    fn refresh_statements() -> Vec<Statement> {
152        vec![
153            "DELETE FROM stats_pred".into(),
154            "INSERT INTO stats_pred(p, triples, distinct_s, distinct_o) \
155             SELECT p, COUNT(*), COUNT(DISTINCT s), COUNT(DISTINCT o) FROM quads GROUP BY p"
156                .into(),
157            "DELETE FROM stats_class".into(),
158            Statement::new(format!(
159                "INSERT INTO stats_class(o, instances) SELECT o, COUNT(*) FROM quads WHERE p = {} GROUP BY o",
160                rdf_type_id()
161            )),
162            "INSERT OR REPLACE INTO oxilite_meta(key, value) SELECT 'total', CAST(COUNT(*) AS TEXT) FROM quads"
163                .into(),
164            "DELETE FROM stats_po".into(),
165            Statement::new(format!(
166                "INSERT INTO stats_po(p, o, n) SELECT q.p, q.o, COUNT(*) FROM quads q JOIN stats_pred sp ON sp.p = q.p \
167                 WHERE sp.distinct_o <= 1024 AND q.p <> {} GROUP BY q.p, q.o, sp.triples, sp.distinct_o \
168                 HAVING COUNT(*) >= 4.0 * sp.triples / sp.distinct_o ORDER BY COUNT(*) DESC LIMIT 2000",
169                rdf_type_id()
170            )),
171        ]
172    }
173}