Skip to main content

core_api/memory/
suggest.rs

1//! The store's own rule proposals, made fit to relay.
2//!
3//! [`crate::GraphDb::suggest_rules_with_config`] profiles the store and
4//! proposes rules. Two things stand between that and something a person
5//! should be shown, and both live here so that every surface shows the same
6//! thing: proposals over fields the store writes for its own bookkeeping are
7//! dropped, and each survivor carries the arguments that create it —
8//! arguments `create_rule` accepts unchanged over MCP and in the Python
9//! binding, and that create the same rule on both.
10
11use crate::explain_digest::predicate_summary;
12use crate::memory::forget::predicate_fields;
13use crate::{GraphDb, PredicateSummary, RuleDef, SuggestConfig, SUGGEST_DEFAULT_SEED};
14use core_storage::fs::Fs;
15use serde_json::{json, Value as Js};
16
17/// Fields the store writes for its own bookkeeping, never proposed as a rule.
18///
19/// On a memory store these are the whole of what the engine would otherwise
20/// propose: measured on a 100,000-node store `remember` filled, every proposal
21/// was `kind`, `source` or `ts` — clique rules that link every note to 32
22/// others for sharing a default value. `ns` is the namespace every namespaced
23/// node carries, `id` repeats the key, `provisional` is `remember`'s stub mark,
24/// `aliases` is the list `remember` maintains for identity matching and
25/// `alias_keys` is the list of aliases a caller declared, kept as written.
26pub const BOOKKEEPING_FIELDS: [&str; 8] = [
27    "ns",
28    "kind",
29    "ts",
30    "source",
31    "provisional",
32    "id",
33    "aliases",
34    "alias_keys",
35];
36
37/// `x` rounded to `places` decimals: a report's floats at the precision its
38/// text prints them, so the two agree and a re-run is byte-identical.
39#[must_use]
40pub fn round_to(x: f64, places: i32) -> f64 {
41    let scale = 10f64.powi(places);
42    (x * scale).round() / scale
43}
44
45/// `def` as `create_rule` arguments: no nulls, `approximate` only when set,
46/// and `weight_prop` explicit — the MCP tool would default a missing one to
47/// `"weight"` and the Python binding would leave it unset, so it is written
48/// out to make what is shown what is created, on both.
49#[must_use]
50pub fn create_rule_args(def: &RuleDef) -> Js {
51    let mut v = serde_json::to_value(def).unwrap_or(Js::Null);
52    if let Some(obj) = v.as_object_mut() {
53        obj.retain(|_, x| !x.is_null());
54        if obj.get("approximate") == Some(&Js::Bool(false)) {
55            obj.remove("approximate");
56        }
57        obj.entry("weight_prop").or_insert_with(|| json!("weight"));
58    }
59    v
60}
61
62/// One proposal, with the arguments that create it.
63#[derive(Debug, Clone, serde::Serialize)]
64pub struct Suggestion {
65    pub name: String,
66    pub src_label: String,
67    pub dst_label: String,
68    pub edge_type: String,
69    /// The predicate in one clause, as `schema` prints it.
70    pub predicate: String,
71    pub est_edges: u64,
72    /// `(src_key, dst_key, score)`, scores at two decimals.
73    pub examples: Vec<(String, String, f64)>,
74    pub rationale: String,
75    /// Pass this object to `create_rule` as its arguments, unchanged.
76    pub create_rule_args: Js,
77}
78
79/// [`filtered_suggestions`]' answer.
80#[derive(Debug, Clone, serde::Serialize)]
81pub struct FilteredSuggestions {
82    /// Every proposal that reads no bookkeeping field, in the engine's order
83    /// (estimated edges, descending). Not capped: a caller that relays them
84    /// to a person cuts the list itself.
85    pub suggestions: Vec<Suggestion>,
86    /// `suggestions.len()`, kept as a field so a caller that cuts the list
87    /// can still say how many there were.
88    pub total: usize,
89    /// Proposals dropped because they read a bookkeeping field.
90    pub bookkeeping_hidden: usize,
91    /// The engine's time budget ran out; a second call may find more.
92    pub truncated: bool,
93}
94
95/// Profile `db` with the default configuration and seed, and return what is
96/// worth proposing. Creates nothing: a rule is never created silently.
97pub fn filtered_suggestions<F: Fs>(db: &GraphDb<F>) -> FilteredSuggestions {
98    let report = db.suggest_rules_with_config(&SuggestConfig::default(), SUGGEST_DEFAULT_SEED);
99    let mut kept: Vec<Suggestion> = Vec::new();
100    let mut hidden = 0usize;
101    for s in report.suggestions {
102        let summary = PredicateSummary::from(&s.def.predicate);
103        if predicate_fields(&summary)
104            .iter()
105            .any(|f| BOOKKEEPING_FIELDS.contains(&f.as_str()))
106        {
107            hidden += 1;
108            continue;
109        }
110        kept.push(Suggestion {
111            create_rule_args: create_rule_args(&s.def),
112            predicate: predicate_summary(&summary),
113            name: s.def.name,
114            src_label: s.def.src_label,
115            dst_label: s.def.dst_label,
116            edge_type: s.def.edge_type,
117            est_edges: s.est_edges,
118            // Fixed precision in the report too, not only in rendered text:
119            // a determinism claim covers both.
120            examples: s
121                .examples
122                .into_iter()
123                .map(|(a, b, score)| (a, b, round_to(score, 2)))
124                .collect(),
125            rationale: s.rationale,
126        });
127    }
128    FilteredSuggestions {
129        total: kept.len(),
130        suggestions: kept,
131        bookkeeping_hidden: hidden,
132        truncated: report.truncated,
133    }
134}