Skip to main content

core_api/memory/
schema.rs

1//! What a store holds and how it is wired: the answer to "what's in here?".
2//!
3//! The session brief ([`crate::memory::brief`]) already knows a store's labels
4//! and edge types, but it is shaped for a hook that runs once per session: it
5//! omits the rules' predicates, every index declaration and the provisional
6//! nodes `remember` stubbed. An assistant on a host with no `SessionStart`
7//! hook has no brief at all. This report is the brief's counts plus those
8//! three, for the MCP `schema` tool.
9//!
10//! [`render_schema`] does **not** stamp the untrusted-data framing line. The
11//! MCP layer's `ok()` is the one place a task reply is framed; a renderer that
12//! framed too would put the line in a reply twice.
13
14use crate::digest::{sanitize, SEP};
15use crate::explain_digest::predicate_summary;
16use crate::memory::brief::{brief, BriefOptions, SchemaBrief};
17use crate::memory_schema::{PROVISIONAL_LABEL, PROVISIONAL_PROP};
18use crate::{CmpOp, Filter, GraphDb, PredicateSummary, Value};
19use core_storage::fs::Fs;
20use serde::Serialize;
21
22/// Entries listed per section before the rest are counted instead.
23pub const SCHEMA_LIST_CAP: usize = 20;
24
25/// Provisional keys named by the report; the rest are counted.
26pub const PROVISIONAL_SAMPLE: usize = 10;
27
28/// The most bytes [`render_schema`] returns.
29///
30/// Every section is capped in lines, which bounds the text only while names
31/// are short: twenty labels of 700 characters render 14 KB. Three times the
32/// session brief's cap, for a report that carries the brief's two listings
33/// and adds the rules and both index lists; an ordinary store — twenty
34/// labels, twenty edge types, the identity preset's rules — renders
35/// about half of it. The `json: true` report is not capped: a program that
36/// asked for the whole report gets it.
37///
38/// The cut is at the first line that does not fit: that line and everything
39/// after it are dropped, a shorter line further down included, so what is
40/// kept is a prefix of the report. A section heading left with no entry
41/// under it goes too.
42pub const SCHEMA_MAX_BYTES: usize = 12_000;
43
44/// Keep whole lines of `out` inside [`SCHEMA_MAX_BYTES`], and say so on a
45/// last line when any were dropped.
46///
47/// Entries are indented and a heading is not, so a kept text that ends on an
48/// unindented line closing with `:` ends on a heading whose first entry was
49/// the line that did not fit. That heading is dropped with it.
50fn cap_schema(out: String) -> String {
51    if out.len() <= SCHEMA_MAX_BYTES {
52        return out;
53    }
54    let note = format!(
55        "(schema truncated at {SCHEMA_MAX_BYTES} bytes; json: true returns the whole report)\n"
56    );
57    let mut kept = String::new();
58    for line in out.lines() {
59        if kept.len() + line.len() + 1 + note.len() > SCHEMA_MAX_BYTES {
60            break;
61        }
62        kept.push_str(line);
63        kept.push('\n');
64    }
65    let last_start = kept.trim_end_matches('\n').rfind('\n').map_or(0, |i| i + 1);
66    let last = kept[last_start..].trim_end_matches('\n');
67    if last.ends_with(':') && !last.starts_with(' ') {
68        kept.truncate(last_start);
69    }
70    kept.push_str(&note);
71    kept
72}
73
74/// One rule, as the schema names it.
75#[derive(Debug, Clone, PartialEq, Serialize)]
76pub struct RuleBrief {
77    pub name: String,
78    pub src_label: String,
79    pub dst_label: String,
80    pub edge_type: String,
81    /// The predicate in one clause: what it compares, on which fields, and
82    /// the threshold.
83    pub predicate: String,
84    /// `None` for a global rule, which links across namespaces.
85    pub namespace: Option<String>,
86}
87
88/// Everything [`render_schema`] prints, for `json: true`.
89#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct SchemaReport {
91    pub brief: SchemaBrief,
92    pub rules: Vec<RuleBrief>,
93    /// `(label, field)` pairs `recall` searches.
94    pub fulltext: Vec<(String, String)>,
95    /// `(label, field)` pairs with an equality index.
96    pub indexes: Vec<(String, String)>,
97    /// How many nodes are provisional — named by `remember` before anything
98    /// described them. They never expire (spec O-4); `forget` removes one.
99    pub provisional: usize,
100    /// The first [`PROVISIONAL_SAMPLE`] of them, in key order.
101    pub provisional_sample: Vec<String>,
102}
103
104/// Every provisional node's key, sorted.
105///
106/// An `Entity` is not necessarily provisional — `upsert_entity` can create one
107/// deliberately — so the mark is read, not the label alone.
108pub fn provisional_keys<F: Fs>(db: &GraphDb<F>) -> Vec<String> {
109    let filter = Filter::Cmp {
110        field: PROVISIONAL_PROP.to_string(),
111        op: CmpOp::Eq,
112        value: Value::Bool(true),
113    };
114    let mut keys: Vec<String> = db
115        .find_nodes(PROVISIONAL_LABEL, &filter)
116        .into_iter()
117        .map(|n| n.key().to_string())
118        .collect();
119    keys.sort();
120    keys
121}
122
123/// The report for `db`. `opts` bounds the brief's part of the work.
124pub fn schema_report<F: Fs>(db: &GraphDb<F>, opts: &BriefOptions) -> SchemaReport {
125    let mut rules: Vec<RuleBrief> = db
126        .rules()
127        .into_iter()
128        .map(|r| RuleBrief {
129            predicate: predicate_summary(&PredicateSummary::from(&r.predicate)),
130            name: r.name,
131            src_label: r.src_label,
132            dst_label: r.dst_label,
133            edge_type: r.edge_type,
134            namespace: r.namespace,
135        })
136        .collect();
137    rules.sort_by(|a, b| a.name.cmp(&b.name));
138    let provisional = provisional_keys(db);
139    SchemaReport {
140        brief: brief(db, opts),
141        rules,
142        fulltext: db.fulltext_pairs(),
143        indexes: db.index_pairs(),
144        provisional: provisional.len(),
145        provisional_sample: provisional.into_iter().take(PROVISIONAL_SAMPLE).collect(),
146    }
147}
148
149/// `items` one per line under `heading`, cut at [`SCHEMA_LIST_CAP`] with the
150/// rest counted. Nothing at all when `items` is empty.
151fn section(out: &mut String, heading: &str, items: &[String]) {
152    if items.is_empty() {
153        return;
154    }
155    out.push_str(heading);
156    out.push_str(":\n");
157    for item in items.iter().take(SCHEMA_LIST_CAP) {
158        out.push_str("  ");
159        out.push_str(item);
160        out.push('\n');
161    }
162    if items.len() > SCHEMA_LIST_CAP {
163        out.push_str(&format!("  … and {} more\n", items.len() - SCHEMA_LIST_CAP));
164    }
165}
166
167fn pairs(ps: &[(String, String)]) -> Vec<String> {
168    ps.iter()
169        .map(|(l, f)| format!("{}.{}", sanitize(l), sanitize(f)))
170        .collect()
171}
172
173/// The report as text, every value sanitized. Unframed: see the module docs.
174#[must_use]
175pub fn render_schema(r: &SchemaReport) -> String {
176    let b = &r.brief;
177    let at_least = if b.partial { "≥ " } else { "" };
178    let mut out = format!(
179        "mushroomdb schema — {at_least}{} node(s){SEP}{} edge(s){SEP}{} label(s)\n",
180        b.nodes,
181        b.edges,
182        b.labels.len(),
183    );
184    let labels: Vec<String> = b
185        .labels
186        .iter()
187        .map(|l| {
188            let props: Vec<String> = l.props.iter().map(|p| sanitize(p)).collect();
189            let hidden = if l.hidden_props > 0 {
190                format!(" (+{} more)", l.hidden_props)
191            } else {
192                String::new()
193            };
194            format!(
195                "{} ({}): {}{hidden}",
196                sanitize(&l.label),
197                l.nodes,
198                props.join(", ")
199            )
200        })
201        .collect();
202    section(&mut out, "labels", &labels);
203    let edge_types: Vec<String> = b
204        .edge_types
205        .iter()
206        .map(|e| {
207            let ends = |v: &[String]| v.iter().map(|s| sanitize(s)).collect::<Vec<_>>().join(", ");
208            let rule = match &e.rule {
209                Some(name) if e.hidden_rules > 0 => {
210                    format!(" — rule {} (+{} more)", sanitize(name), e.hidden_rules)
211                }
212                Some(name) => format!(" — rule {}", sanitize(name)),
213                None => String::new(),
214            };
215            format!(
216                "{} ({}) {} → {}{rule}",
217                sanitize(&e.edge_type),
218                e.edges,
219                ends(&e.src),
220                ends(&e.dst)
221            )
222        })
223        .collect();
224    section(&mut out, "edge types", &edge_types);
225    let rules: Vec<String> = r
226        .rules
227        .iter()
228        .map(|rule| {
229            let scope = match &rule.namespace {
230                Some(ns) => format!("namespace {}", sanitize(ns)),
231                None => "global".to_string(),
232            };
233            format!(
234                "{}: {} → {} derives {} — {} ({scope})",
235                sanitize(&rule.name),
236                sanitize(&rule.src_label),
237                sanitize(&rule.dst_label),
238                sanitize(&rule.edge_type),
239                rule.predicate
240            )
241        })
242        .collect();
243    section(&mut out, "rules", &rules);
244    section(
245        &mut out,
246        "full-text (recall searches these)",
247        &pairs(&r.fulltext),
248    );
249    section(&mut out, "equality indexes", &pairs(&r.indexes));
250    if r.provisional > 0 {
251        let named: Vec<String> = r.provisional_sample.iter().map(|k| sanitize(k)).collect();
252        let more = r.provisional.saturating_sub(named.len());
253        out.push_str(&format!(
254            "provisional: {} — named but not yet described: {}{}\n",
255            r.provisional,
256            named.join(", "),
257            if more > 0 {
258                format!(" (+{more} more)")
259            } else {
260                String::new()
261            }
262        ));
263    }
264    if b.partial {
265        out.push_str("(partial: the time budget ran out; counts are lower bounds)\n");
266    }
267    cap_schema(out)
268}