Skip to main content

qualia_core_db/q42/volume/
inspect.rs

1//! Truthful inspection of a unified Q42 volume. Cold path — may allocate.
2
3use std::io;
4use std::path::Path;
5
6use serde::Serialize;
7
8use super::super::{
9    Q42Volume, Q42VolumeHeader, FLAG_BLOCKS_LZ4, FLAG_FIELD_POSTINGS, FLAG_FIELD_RANGES,
10    FLAG_OBJECT_SORTED, FLAG_PERMISSIVE_COMMONS, FLAG_SANCTUARY, FLAG_VOLUME_ROOT, HEADER_SIZE,
11};
12use super::publication::{classify_q42_volume, PublicationIntent, Q42PublicationVerdict};
13/// One named byte interval inside the file.
14#[derive(Clone, Debug, Serialize)]
15pub struct Q42SectionReport {
16    pub name: String,
17    pub offset: u64,
18    pub length: u64,
19}
20
21/// Machine-readable inspect receipt. Empty lexicon and missing postings are
22/// named, not papered over.
23#[derive(Clone, Debug, Serialize)]
24pub struct Q42InspectReport {
25    pub path: String,
26    pub file_bytes: u64,
27    pub version: u16,
28    pub flags: u16,
29    pub flag_names: Vec<&'static str>,
30    pub block_count: u64,
31    pub block_size: u32,
32    pub quins_per_block: u32,
33    pub lexicon_bytes: u64,
34    pub lexicon_entries: Option<u64>,
35    /// True when this file stores no terms. Q42LEX is implemented; the artifact
36    /// was written hashed-only (or is a catalog root).
37    pub lexicon_has_no_terms: bool,
38    pub has_bidx: bool,
39    pub has_field_ranges: bool,
40    pub has_field_postings: bool,
41    pub is_volume_root: bool,
42    pub publication_class: String,
43    pub publication_transport: String,
44    pub may_public_magnet: bool,
45    pub publication_reason: String,
46    pub sections: Vec<Q42SectionReport>,
47    pub honesty: Vec<String>,
48}
49
50impl Q42InspectReport {
51    pub fn from_path(path: &Path) -> io::Result<Self> {
52        let file_bytes = std::fs::metadata(path)?.len();
53        let volume = Q42Volume::open(path)?;
54        Ok(Self::from_volume(path, file_bytes, &volume))
55    }
56
57    pub fn from_volume(path: &Path, file_bytes: u64, volume: &Q42Volume) -> Self {
58        let header = volume.header();
59        let flags = header.flags;
60        let lex_length = header.lex_length;
61        let version = header.version;
62        let block_count = header.block_count;
63        let block_size = header.block_size;
64        let quins_per_block = header.quins_per_block;
65        let lexicon_entries = volume.lex_view().ok().map(|lex| lex.entry_count() as u64);
66        let lexicon_has_no_terms = lexicon_entries == Some(0) || lex_length == 0;
67        let mut honesty = Vec::new();
68        let shared_lex_shards = volume
69            .volume_manifest()
70            .ok()
71            .flatten()
72            .map(|manifest| manifest.lexicon_segments.len() as u64)
73            .unwrap_or(0);
74        if lexicon_has_no_terms && block_count > 0 && shared_lex_shards == 0 {
75            honesty.push(
76                "This file has a valid Q42LEX with 0 terms. If it is a volume-set child, terms live in the root's lexicon shards; otherwise this artifact was written hashed-only."
77                    .into(),
78            );
79        }
80        if shared_lex_shards > 0 {
81            honesty.push(format!(
82                "Volume-set lexicon is sharded across {shared_lex_shards} child .q42 files named by this root. Local Q42LEX being empty here is expected."
83            ));
84        }
85        if flags & FLAG_FIELD_POSTINGS == 0 && block_count > 0 {
86            honesty.push(
87                "This file has no PIDX section. Newer writes include compact S/P/C postings; this artifact can be rewritten to add them."
88                    .into(),
89            );
90        }
91        if flags & FLAG_VOLUME_ROOT != 0 {
92            honesty.push(
93                "Volume root: the catalog is here; graph SuperBlocks are in the child .q42 segments it names."
94                    .into(),
95            );
96        }
97        if flags & FLAG_OBJECT_SORTED == 0 && block_count > 0 {
98            honesty.push(
99                "This file does not declare object sort order, so BIDX range pruning must not be trusted for it."
100                    .into(),
101            );
102        }
103        let publication = classify_q42_volume(volume, PublicationIntent::Default);
104        push_publication_notes(&mut honesty, &publication);
105
106        Self {
107            path: path.display().to_string(),
108            file_bytes,
109            version,
110            flags,
111            flag_names: decode_flags(flags),
112            block_count,
113            block_size,
114            quins_per_block,
115            lexicon_bytes: lex_length,
116            lexicon_entries,
117            lexicon_has_no_terms,
118            has_bidx: header.bidx_length > 0,
119            has_field_ranges: flags & FLAG_FIELD_RANGES != 0,
120            has_field_postings: flags & FLAG_FIELD_POSTINGS != 0,
121            is_volume_root: flags & FLAG_VOLUME_ROOT != 0,
122            publication_class: publication.class.as_str().into(),
123            publication_transport: publication.transport.as_str().into(),
124            may_public_magnet: publication.may_emit_public_magnet,
125            publication_reason: publication.reason,
126            sections: collect_sections(header),
127            honesty,
128        }
129    }
130
131    pub fn to_text(&self) -> String {
132        let mut out = String::new();
133        out.push_str(&format!("Q42  {}\n", self.path));
134        out.push_str(&format!(
135            "  file        {} bytes\n",
136            self.file_bytes
137        ));
138        out.push_str(&format!(
139            "  version     {}   flags 0x{:04x} ({})\n",
140            self.version,
141            self.flags,
142            self.flag_names.join(" | ")
143        ));
144        out.push_str(&format!(
145            "  blocks      {} × {} bytes ({} Quins/block capacity)\n",
146            self.block_count, self.block_size, self.quins_per_block
147        ));
148        out.push_str(&format!(
149            "  lexicon     {} bytes, {} entries{}\n",
150            self.lexicon_bytes,
151            self.lexicon_entries
152                .map(|n| n.to_string())
153                .unwrap_or_else(|| "?".into()),
154            if self.lexicon_has_no_terms {
155                "  [0 terms in this file]"
156            } else {
157                ""
158            }
159        ));
160        out.push_str(&format!(
161            "  indexes     BIDX={}  FIDX={}  PIDX={}  root={}\n",
162            yn(self.has_bidx),
163            yn(self.has_field_ranges),
164            yn(self.has_field_postings),
165            yn(self.is_volume_root)
166        ));
167        out.push_str(&format!(
168            "  publication {}\n",
169            self.publication_class
170        ));
171        out.push_str(&format!(
172            "  transport   {}\n",
173            self.publication_transport
174        ));
175        out.push_str(&format!(
176            "  public magnet {}\n",
177            yn(self.may_public_magnet)
178        ));
179        if !self.publication_reason.is_empty() {
180            out.push_str(&format!("  publish note {}\n", self.publication_reason));
181        }
182        out.push_str("  sections\n");
183        for section in &self.sections {
184            out.push_str(&format!(
185                "    {:<18} {:>12} + {}\n",
186                section.name, section.offset, section.length
187            ));
188        }
189        if !self.honesty.is_empty() {
190            out.push_str("  honesty\n");
191            for note in &self.honesty {
192                out.push_str(&format!("    - {note}\n"));
193            }
194        }
195        out
196    }
197}
198
199fn yn(value: bool) -> &'static str {
200    if value {
201        "yes"
202    } else {
203        "no"
204    }
205}
206
207fn push_publication_notes(honesty: &mut Vec<String>, publication: &Q42PublicationVerdict) {
208    match publication.class {
209        super::publication::Q42PublicationClass::UnmarkedLocal => honesty.push(
210            "Unmarked volume: no public magnet unless a human marks it as a Permissive Commons catalog (--commons or FLAG_PERMISSIVE_COMMONS). Personal and medical records stay local / SocialWebNet."
211                .into(),
212        ),
213        super::publication::Q42PublicationClass::Sanctuary => honesty.push(
214            "Sanctuary volume: public magnet, HTTP web-seed, and IPFS are denied. Transport is local or SocialWebNet (pairwise DID)."
215                .into(),
216        ),
217        super::publication::Q42PublicationClass::MixedFailClosed => honesty.push(
218            "Mixed volume: Commons and Selfhood Quins share one file. Split before any public hash is emitted."
219                .into(),
220        ),
221        super::publication::Q42PublicationClass::PermissiveCommons
222        | super::publication::Q42PublicationClass::CommonsGated => honesty.push(
223            "Permissive Commons transport (hash-addressed). This is not open-data: Selfhood stays out, and consume-side TrustGroup / billing gates still apply."
224                .into(),
225        ),
226    }
227}
228
229fn decode_flags(flags: u16) -> Vec<&'static str> {
230    let mut names = Vec::new();
231    if flags & FLAG_BLOCKS_LZ4 != 0 {
232        names.push("lz4");
233    }
234    if flags & FLAG_OBJECT_SORTED != 0 {
235        names.push("object-sorted");
236    }
237    if flags & FLAG_VOLUME_ROOT != 0 {
238        names.push("volume-root");
239    }
240    if flags & FLAG_FIELD_RANGES != 0 {
241        names.push("field-ranges");
242    }
243    if flags & FLAG_FIELD_POSTINGS != 0 {
244        names.push("field-postings");
245    }
246    if flags & FLAG_PERMISSIVE_COMMONS != 0 {
247        names.push("permissive-commons");
248    }
249    if flags & FLAG_SANCTUARY != 0 {
250        names.push("sanctuary");
251    }
252    let known = FLAG_BLOCKS_LZ4
253        | FLAG_OBJECT_SORTED
254        | FLAG_VOLUME_ROOT
255        | FLAG_FIELD_RANGES
256        | FLAG_FIELD_POSTINGS
257        | FLAG_PERMISSIVE_COMMONS
258        | FLAG_SANCTUARY;
259    if flags & !known != 0 {
260        names.push("unknown");
261    }
262    if names.is_empty() {
263        names.push("none");
264    }
265    names
266}
267
268fn collect_sections(header: &Q42VolumeHeader) -> Vec<Q42SectionReport> {
269    let mut sections = vec![Q42SectionReport {
270        name: "header".into(),
271        offset: 0,
272        length: HEADER_SIZE as u64,
273    }];
274    let mut push = |name: &str, offset: u64, length: u64| {
275        if length > 0 {
276            sections.push(Q42SectionReport {
277                name: name.into(),
278                offset,
279                length,
280            });
281        }
282    };
283    push("lexicon", header.lex_offset, header.lex_length);
284    if let Some((offset, length)) = header.volume_manifest_range() {
285        push("volume-manifest", offset, length);
286    }
287    push("bidx", header.bidx_offset, header.bidx_length);
288    if let Some((offset, length)) = header.field_range_index_range() {
289        push("field-ranges", offset, length);
290    }
291    if let Some((offset, length)) = header.field_postings_range() {
292        push("field-postings", offset, length);
293    }
294    push(
295        "block-directory",
296        header.block_dir_offset,
297        header.block_dir_length,
298    );
299    push("block-data", header.data_offset, header.data_length);
300    push(
301        "temporal-index",
302        header.temporal_index_offset,
303        header.temporal_index_length,
304    );
305    push("merkle-dag", header.dag_root_offset, header.dag_root_length);
306    sections
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::q42_volume::{write_unified_volume, StreamingQ42VolumeWriter};
313    use crate::NQuin;
314    use std::collections::HashMap;
315
316    #[test]
317    fn inspect_names_empty_lex_and_streaming_postings() {
318        let file = tempfile::NamedTempFile::new().unwrap();
319        write_unified_volume(
320            file.path(),
321            &HashMap::new(),
322            &[(3, 3)],
323            &[vec![NQuin {
324                subject: 1,
325                predicate: 2,
326                object: 3,
327                context: 0,
328                metadata: 0,
329                parity: 0,
330            }]],
331        )
332        .unwrap();
333        let report = Q42InspectReport::from_path(file.path()).unwrap();
334        assert!(report.lexicon_has_no_terms);
335        assert!(report.honesty.iter().any(|n| n.contains("0 terms")));
336        assert_eq!(report.publication_class, "unmarked-local");
337        assert!(!report.may_public_magnet);
338
339        let streamed = tempfile::NamedTempFile::new().unwrap();
340        let mut lex = HashMap::new();
341        lex.insert(1, "s".into());
342        lex.insert(2, "p".into());
343        lex.insert(3, "o".into());
344        let mut writer = StreamingQ42VolumeWriter::new(&lex).unwrap();
345        writer
346            .push_block(
347                0,
348                &[NQuin {
349                    subject: 1,
350                    predicate: 2,
351                    object: 3,
352                    context: 0,
353                    metadata: 0,
354                    parity: 0,
355                }],
356            )
357            .unwrap();
358        writer.finish(streamed.path()).unwrap();
359        let good = Q42InspectReport::from_path(streamed.path()).unwrap();
360        assert!(good.has_field_postings);
361        assert!(!good.lexicon_has_no_terms);
362    }
363}