Skip to main content

vole_document/field/
explain.rs

1//! EXPLAIN and EXPLAIN ANALYZE for field observations (Phase 11.5, ADR-0026).
2//!
3//! [`explain`] is a pure function of validated metadata: it never materializes a
4//! node and never writes to the store. [`explain_analyze`] executes the
5//! observation and returns both the intended plan and the measured actual work,
6//! which is the central evidence surface for Phase-11 claims (ADR-0027).
7//!
8//! The JSON key sets are frozen and exact:
9//!
10//! * plan   — `selector`, `representation`, `format`, `adapter`, `capability`,
11//!   `index_route`, `shape`, `index_reads`, `required_nodes`,
12//!   `will_materialize`, `will_not_materialize`.
13//! * actual — `format`, `adapter`, `index_nodes_read`, `seed_nodes_fetched`,
14//!   `seed_nodes_materialized`, `member_decodes`, `xml_parses`,
15//!   `nodes_id_shared`, `shared_resource_ids`, `inverse_work_units`,
16//!   `descriptor_bytes_read`, `descriptor_read_mode`, `manifest_bytes_read`,
17//!   `index_bytes_read`, `seed_bytes_read`, `bytes_read`, `bytes_returned`,
18//!   `deepened`, `whole_source_materialized`, `wall_micros`, `basis`, `exact`.
19//!
20//! `format`/`adapter` are the detected format and its adapter; `capability` is the
21//! resolved path (`native`, or `common:<selector> -> <adapter>`); `index_route` is
22//! `hier-index`/`none`. `member_decodes`/`xml_parses` are observation-boundary
23//! materialization requests (see [`ObserveStats`]); `whole_source_materialized` is
24//! yes exactly when the answer verified whole-source integrity.
25//!
26//! `bytes_read` is the **sum** of the four `*_bytes_read` classes: total physical
27//! bytes this observation made the OS fetch, including the descriptor blob. It is
28//! deliberately not the seed-store-only number it was before Phase 11.9's
29//! review (ADR-0027 accounting).
30
31use crate::error::Result;
32use crate::field::document_format::DocumentFormat;
33use crate::field::manifest::FieldRoot;
34use crate::field::observe::{ObserveRequest, ObserveStats, OpenedField, observe_opened};
35use crate::field::plan::{ObservePlan, plan};
36use crate::field::provenance::{Basis, IntegrityScope, json_escape};
37use crate::field::{FieldId, FieldStore};
38use crate::limits::Limits;
39
40/// The intended plan plus its canonical JSON rendering.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExplainPlan {
43    /// The structured plan.
44    pub plan: ObservePlan,
45    /// The plan as a flat JSON object with exactly the documented keys.
46    pub json: String,
47}
48
49/// The measured cost of an executed observation.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ExplainActual {
52    /// The observation's stats.
53    pub stats: ObserveStats,
54    /// The basis of the answer that was produced.
55    pub answer_basis: Basis,
56    /// Whether the answer was exact.
57    pub exact: bool,
58    /// The detected document format (`"unknown"` when the manifest predates it).
59    pub format: String,
60    /// The adapter that served the answer.
61    pub adapter: String,
62    /// Whether the whole source was materialized (whole-source integrity checked).
63    pub whole_source_materialized: bool,
64}
65
66impl ExplainActual {
67    /// The actual work as a flat JSON object with exactly the documented keys.
68    pub fn to_json(&self) -> String {
69        let s = &self.stats;
70        format!(
71            concat!(
72                "{{",
73                "\"format\":\"{}\",",
74                "\"adapter\":\"{}\",",
75                "\"index_nodes_read\":{},",
76                "\"seed_nodes_fetched\":{},",
77                "\"seed_nodes_materialized\":{},",
78                "\"member_decodes\":{},",
79                "\"xml_parses\":{},",
80                "\"nodes_id_shared\":{},",
81                "\"shared_resource_ids\":{},",
82                "\"inverse_work_units\":{},",
83                "\"descriptor_bytes_read\":{},",
84                "\"descriptor_read_mode\":\"{}\",",
85                "\"manifest_bytes_read\":{},",
86                "\"index_bytes_read\":{},",
87                "\"seed_bytes_read\":{},",
88                "\"bytes_read\":{},",
89                "\"bytes_returned\":{},",
90                "\"deepened\":{},",
91                "\"whole_source_materialized\":{},",
92                "\"wall_micros\":{},",
93                "\"basis\":\"{}\",",
94                "\"exact\":{}",
95                "}}"
96            ),
97            json_escape(&self.format),
98            json_escape(&self.adapter),
99            s.index_nodes_read,
100            s.seed_nodes_fetched,
101            s.seed_nodes_materialized,
102            s.member_decodes,
103            s.xml_parses,
104            s.nodes_id_shared,
105            s.shared_resource_ids,
106            s.seed_nodes_executed.saturating_add(s.bytes_read),
107            s.descriptor_bytes_read,
108            s.descriptor_read_mode.name(),
109            s.manifest_bytes_read,
110            s.index_bytes_read,
111            s.seed_bytes_read,
112            s.bytes_read,
113            s.bytes_returned,
114            s.deepened,
115            self.whole_source_materialized,
116            s.wall_micros,
117            self.answer_basis.name(),
118            self.exact,
119        )
120    }
121}
122
123/// Explain an observation without executing it. Pure.
124pub fn explain(
125    manifest: &FieldRoot,
126    store: &FieldStore,
127    req: &ObserveRequest,
128) -> Result<ExplainPlan> {
129    let plan = plan(manifest, store, req)?;
130    let json = plan_json(req, &plan, manifest);
131    Ok(ExplainPlan { plan, json })
132}
133
134/// Execute an observation and report both the intended plan and actual work.
135///
136/// The field is opened **once** here and reused for both the plan and the
137/// evaluation (`observe_with_field`), so a cold `explain --analyze` reads the
138/// descriptor blob exactly once (review fix #2). The reported `wall_micros`
139/// covers the whole analysis: open + plan + evaluation.
140pub fn explain_analyze(
141    store: &mut FieldStore,
142    id: &FieldId,
143    req: &ObserveRequest,
144    limits: Limits,
145) -> Result<(ExplainPlan, ExplainActual)> {
146    let started = std::time::Instant::now();
147    let opened = OpenedField::open(store, id, req, limits)?;
148    let planned = plan(opened.manifest(), store, req)?;
149    let json = plan_json(req, &planned, opened.manifest());
150    let (answer, mut stats, _) = observe_opened(store, &opened, req, limits)?;
151    stats.wall_micros = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
152    let fmt = DocumentFormat::from_provenance(&opened.manifest().provenance);
153    let actual = ExplainActual {
154        stats,
155        answer_basis: answer.basis,
156        exact: answer.exact,
157        format: fmt.map_or("unknown", |f| f.name()).to_string(),
158        adapter: fmt.map_or("unknown", |f| f.adapter()).to_string(),
159        whole_source_materialized: answer.integrity_scope == IntegrityScope::WholeSource,
160    };
161    Ok((
162        ExplainPlan {
163            plan: planned,
164            json,
165        },
166        actual,
167    ))
168}
169
170fn plan_json(req: &ObserveRequest, plan: &ObservePlan, manifest: &FieldRoot) -> String {
171    let fmt = DocumentFormat::from_provenance(&manifest.provenance);
172    let format_name = fmt.map_or("unknown", |f| f.name());
173    let adapter = fmt.map_or("unknown", |f| f.adapter());
174    let capability = match crate::field::capabilities::common_selector_name(&req.selector) {
175        Some(name) => format!("common:{name} -> {adapter}"),
176        None => "native".to_string(),
177    };
178    let index_route = if manifest.has_index() {
179        "hier-index"
180    } else {
181        "none"
182    };
183    format!(
184        concat!(
185            "{{",
186            "\"selector\":\"{}\",",
187            "\"representation\":\"{}\",",
188            "\"format\":\"{}\",",
189            "\"adapter\":\"{}\",",
190            "\"capability\":\"{}\",",
191            "\"index_route\":\"{}\",",
192            "\"shape\":\"{}\",",
193            "\"index_reads\":{},",
194            "\"required_nodes\":{},",
195            "\"will_materialize\":{},",
196            "\"will_not_materialize\":{}",
197            "}}"
198        ),
199        json_escape(&req.selector.canonical()),
200        json_escape(req.representation.name()),
201        json_escape(format_name),
202        json_escape(adapter),
203        json_escape(&capability),
204        index_route,
205        plan.shape.name(),
206        plan.index_reads,
207        plan.required_nodes,
208        string_array(&plan.will_materialize),
209        string_array(&plan.will_not_materialize),
210    )
211}
212
213fn string_array(items: &[String]) -> String {
214    let body = items
215        .iter()
216        .map(|s| format!("\"{}\"", json_escape(s)))
217        .collect::<Vec<_>>()
218        .join(",");
219    format!("[{body}]")
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn actual_json_has_exactly_the_documented_keys() {
228        let actual = ExplainActual {
229            stats: ObserveStats::default(),
230            answer_basis: Basis::DirectlyObserved,
231            exact: true,
232            format: "unknown".to_string(),
233            adapter: "unknown".to_string(),
234            whole_source_materialized: false,
235        };
236        assert_eq!(
237            actual.to_json(),
238            "{\"format\":\"unknown\",\"adapter\":\"unknown\",\"index_nodes_read\":0,\"seed_nodes_fetched\":0,\"seed_nodes_materialized\":0,\"member_decodes\":0,\"xml_parses\":0,\"nodes_id_shared\":0,\"shared_resource_ids\":0,\"inverse_work_units\":0,\"descriptor_bytes_read\":0,\"descriptor_read_mode\":\"full\",\"manifest_bytes_read\":0,\"index_bytes_read\":0,\"seed_bytes_read\":0,\"bytes_read\":0,\"bytes_returned\":0,\"deepened\":false,\"whole_source_materialized\":false,\"wall_micros\":0,\"basis\":\"directly-observed\",\"exact\":true}"
239        );
240    }
241
242    #[test]
243    fn string_array_escapes() {
244        assert_eq!(
245            string_array(&["a".into(), "b\"c".into()]),
246            "[\"a\",\"b\\\"c\"]"
247        );
248        assert_eq!(string_array(&[]), "[]");
249    }
250}