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}