Skip to main content

kimetsu_brain/
fact_query.rs

1//! Scope-bound requests and evidence accounting for explicit configuration facts.
2#[derive(Debug, Clone, PartialEq, Eq)]
3pub struct FactRequest {
4    pub subject: String,
5    pub environment: Option<String>,
6    pub attributes: Vec<String>,
7}
8
9fn folded(text: &str) -> String {
10    text.to_lowercase()
11        .chars()
12        .map(|c| match c {
13            'á' => 'a',
14            'é' => 'e',
15            'í' => 'i',
16            'ó' => 'o',
17            'ú' | 'ü' => 'u',
18            'ñ' => 'n',
19            _ => c,
20        })
21        .collect()
22}
23fn attribute(text: &str) -> Option<String> {
24    Some(
25        match text.trim().trim_matches('`') {
26            "port" | "puerto" => "port",
27            "timeout" | "tiempo de espera" => "timeout",
28            "version" | "release number" => "version",
29            "replicas" | "replica count" => "replicas",
30            "password" | "passphrase" | "contrasena" => "password",
31            "encryption key" | "clave de cifrado" => "encryption_key",
32            "retries" | "retry count" | "reintentos" => "retries",
33            "retention" | "retencion" => "retention",
34            "memory limit" | "limite de memoria" => "memory_limit",
35            key if key.contains(['.', '_'])
36                && key
37                    .chars()
38                    .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) =>
39            {
40                key
41            }
42            _ => return None,
43        }
44        .to_owned(),
45    )
46}
47fn attributes(text: &str) -> Option<Vec<String>> {
48    let text = text.replace(" and ", ",").replace(" y ", ",");
49    let mut result = Vec::new();
50    for part in text.split(',') {
51        let attr = attribute(part)?;
52        if !result.contains(&attr) {
53            result.push(attr);
54        }
55    }
56    if result.is_empty() || result.len() > 4 {
57        None
58    } else {
59        Some(result)
60    }
61}
62fn request(subject: &str, attrs: &str) -> Option<FactRequest> {
63    let (subject, environment) = crate::facts::canonical_subject(subject);
64    if subject.is_empty()
65        || subject.split_whitespace().any(|w| {
66            matches!(
67                w,
68                "effect"
69                    | "impact"
70                    | "cause"
71                    | "causes"
72                    | "changing"
73                    | "configure"
74                    | "configuration"
75                    | "best"
76                    | "meaning"
77                    | "difference"
78            )
79        })
80    {
81        return None;
82    }
83    Some(FactRequest {
84        subject,
85        environment,
86        attributes: attributes(attrs)?,
87    })
88}
89/// Recognize direct attribute questions only, with one explicit shared subject.
90/// Multi-subject, explanatory and unsupported language retain normal retrieval.
91pub fn parse(query: &str) -> Option<FactRequest> {
92    if query.len() > 1024 {
93        return None;
94    }
95    let normalized = folded(query);
96    let q = normalized.trim_matches(['¿', '?', ' ', '.', '\n', '\t']);
97    if let Some(rest) = q.strip_prefix("what ") {
98        if let Some((attrs, subject)) = rest.split_once(" does ") {
99            for suffix in [" use", " require", " run", " have"] {
100                if let Some(subject) = subject.strip_suffix(suffix) {
101                    return request(subject, attrs);
102                }
103            }
104            return None;
105        }
106    }
107    let rest = [
108        "what is ",
109        "what are ",
110        "what's ",
111        "which is ",
112        "which are ",
113        "cual es ",
114        "cuales son ",
115        "que es ",
116    ]
117    .iter()
118    .find_map(|p| q.strip_prefix(p))?;
119    let rest = ["the ", "el ", "la ", "los ", "las "]
120        .iter()
121        .find_map(|p| rest.strip_prefix(p))
122        .unwrap_or(rest);
123    for separator in [" for ", " of ", " del ", " de "] {
124        if let Some((attrs, subject)) = rest.split_once(separator) {
125            if attributes(attrs).is_some() {
126                return request(subject, attrs);
127            }
128        }
129    }
130    // Split at every word boundary: exactly one split must form a complete
131    // attribute list; arbitrary suffix words cannot be silently ignored.
132    for (index, ch) in rest.char_indices() {
133        if ch == ' ' && attributes(&rest[index + 1..]).is_some() {
134            return request(&rest[..index], &rest[index + 1..]);
135        }
136    }
137    None
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
141pub struct SupportedFact {
142    pub attribute: String,
143    pub value: String,
144    pub sources: Vec<String>,
145}
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
147pub struct FactAssessment {
148    pub status: String,
149    pub subject: String,
150    pub environment: Option<String>,
151    pub supported: Vec<SupportedFact>,
152    pub missing: Vec<String>,
153    pub conflicting: Vec<String>,
154}
155pub fn matches(request: &FactRequest, claim: &crate::facts::FactClaim) -> bool {
156    request.subject == claim.subject
157        && request.environment == claim.environment
158        && request.attributes.contains(&claim.attribute)
159}
160pub fn assess(
161    request: &FactRequest,
162    claims: &[(&crate::facts::FactClaim, &str)],
163) -> FactAssessment {
164    use std::collections::{BTreeMap, BTreeSet};
165    let mut supported = Vec::new();
166    let mut missing = Vec::new();
167    let mut conflicting = Vec::new();
168    for attribute in &request.attributes {
169        let mut values: BTreeMap<String, (&str, BTreeSet<&str>)> = BTreeMap::new();
170        for (claim, source) in claims {
171            if matches(request, claim) && claim.attribute == *attribute {
172                values
173                    .entry(crate::fact_values::equivalence_key(attribute, &claim.value))
174                    .or_insert_with(|| (claim.value.as_str(), BTreeSet::new()))
175                    .1
176                    .insert(source);
177            }
178        }
179        match values.len() {
180            0 => missing.push(attribute.clone()),
181            1 => {
182                let (_, (value, sources)) = values.into_iter().next().unwrap();
183                supported.push(SupportedFact {
184                    attribute: attribute.clone(),
185                    value: value.into(),
186                    sources: sources.into_iter().map(str::to_owned).collect(),
187                });
188            }
189            _ => conflicting.push(attribute.clone()),
190        }
191    }
192    let status = if !conflicting.is_empty() {
193        "conflicting"
194    } else if supported.is_empty() {
195        "missing"
196    } else if missing.is_empty() {
197        "supported"
198    } else {
199        "partial"
200    };
201    FactAssessment {
202        status: status.into(),
203        subject: request.subject.clone(),
204        environment: request.environment.clone(),
205        supported,
206        missing,
207        conflicting,
208    }
209}
210
211/// Retain conflicts already observed before output-budget trimming. Sources in
212/// supported claims still come exclusively from the final delivered slice.
213pub fn preserve_conflicts(result: &mut FactAssessment, known: &[String]) {
214    for attr in known {
215        result.supported.retain(|s| &s.attribute != attr);
216        result.missing.retain(|s| s != attr);
217        if !result.conflicting.contains(attr) {
218            result.conflicting.push(attr.clone());
219        }
220    }
221    if !result.conflicting.is_empty() {
222        result.status = "conflicting".into();
223    }
224}
225/// Facts can support only the claim and exact excerpt that are actually visible.
226pub fn visible(
227    capsule: &crate::context::ContextCapsule,
228    fact: &crate::fact_store::StoredFact,
229) -> bool {
230    capsule.expansion_handle.strip_prefix("memory:") == Some(fact.memory_id.as_str())
231        && capsule.claim_revision.as_deref() == Some(fact.claim_revision.as_str())
232        && !fact.claim.evidence.is_empty()
233        && capsule.summary.contains(&fact.claim.evidence)
234}
235pub fn evaluate(
236    query: &str,
237    capsules: &[crate::context::ContextCapsule],
238) -> Option<FactAssessment> {
239    let request = parse(query)?;
240    let claims: Vec<_> = capsules
241        .iter()
242        .flat_map(|c| {
243            c.facts
244                .iter()
245                .filter(move |f| visible(c, f))
246                .map(move |f| (&f.claim, c.expansion_handle.as_str()))
247        })
248        .collect();
249    Some(assess(&request, &claims))
250}
251pub fn compress_capsule(
252    query: &str,
253    capsule: &crate::context::ContextCapsule,
254    sentences: usize,
255) -> String {
256    let short =
257        crate::answerability::compress_preserving_evidence(query, &capsule.summary, sentences);
258    if parse(query).is_some_and(|request| {
259        capsule.facts.iter().any(|f| {
260            visible(capsule, f) && matches(&request, &f.claim) && !short.contains(&f.claim.evidence)
261        })
262    }) {
263        capsule.summary.clone()
264    } else {
265        short
266    }
267}
268pub fn notice(query: &str, capsules: &[crate::context::ContextCapsule]) -> Option<String> {
269    notice_with_conflicts(query, capsules, &[])
270}
271pub fn notice_with_conflicts(
272    query: &str,
273    capsules: &[crate::context::ContextCapsule],
274    known: &[String],
275) -> Option<String> {
276    let mut result = evaluate(query, capsules)?;
277    preserve_conflicts(&mut result, known);
278    if result.status == "supported" {
279        return None;
280    }
281    let mut details = Vec::new();
282    if !result.missing.is_empty() {
283        details.push(format!(
284            "no supported value for {}",
285            result.missing.join(", ")
286        ));
287    }
288    if !result.conflicting.is_empty() {
289        details.push(format!(
290            "conflicting values for {}",
291            result.conflicting.join(", ")
292        ));
293    }
294    Some(format!("Retrieved fact evidence: {}.", details.join("; ")))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    pub(crate) fn capsule(text: &str, id: &str) -> crate::context::ContextCapsule {
301        let mut c =
302            crate::context::ContextCapsule::wire_minimal(text.into(), "memory".into(), 0.99);
303        c.expansion_handle = format!("memory:{id}");
304        c.claim_revision = Some(format!("baseline:{id}"));
305        c.facts = crate::facts::extract(text)
306            .into_iter()
307            .map(|claim| crate::fact_store::StoredFact {
308                memory_id: id.into(),
309                claim_revision: format!("baseline:{id}"),
310                source_event_id: "accepted-event".into(),
311                valid_from: None,
312                valid_to: None,
313                claim,
314            })
315            .collect();
316        c
317    }
318    #[test]
319    fn only_visible_revision_bound_facts_support_an_answer() {
320        let q = "What is the Orchid staging gateway port?";
321        let mut c = capsule("Orchid staging gateway port is 7319.", "port");
322        assert_eq!(evaluate(q, &[c.clone()]).unwrap().status, "supported");
323        c.claim_revision = Some("different-revision".into());
324        assert_eq!(evaluate(q, &[c.clone()]).unwrap().status, "missing");
325        c.claim_revision = Some("baseline:port".into());
326        c.summary = "Unrelated text.".into();
327        assert_eq!(evaluate(q, &[c]).unwrap().status, "missing");
328    }
329    #[test]
330    fn compression_preserves_each_supported_attribute() {
331        let q = "What are the Orchid gateway port and timeout?";
332        let c = capsule(
333            "Orchid gateway port is 7319. Other setup notes. More setup notes. Orchid gateway timeout is 30 seconds.",
334            "settings",
335        );
336        assert!(compress_capsule(q, &c, 3).contains("30 seconds"));
337    }
338    #[test]
339    fn budget_trimming_does_not_hide_a_known_conflict() {
340        use crate::context::ContextBundle;
341        let q = "What is the Orchid gateway port?";
342        let bundle = ContextBundle {
343            stage: "localization".into(),
344            budget_tokens: 6000,
345            used_tokens: 0,
346            capsules: vec![
347                capsule("Orchid gateway port is 7319.", "a"),
348                capsule("Orchid gateway port is 7320.", "b"),
349            ],
350            excluded: vec![],
351            skipped: false,
352            top_score: 0.99,
353            top_abs_evidence: 0.99,
354            evidence_coverage: 1.0,
355            uncovered_terms: vec![],
356            chronological: false,
357            known_fact_conflicts: vec![],
358        };
359        let mut found = false;
360        for budget in (300..2000).step_by(20) {
361            let delivered = crate::serving::ServingPolicy {
362                budget,
363                explicit_fact_guard: true,
364                ..Default::default()
365            }
366            .render_for_query(
367                q,
368                bundle.clone(),
369                true,
370                crate::serving::EVAL_EXPOSURE_ID,
371            );
372            if delivered.capsules.len() == 1 {
373                found = true;
374                assert_eq!(delivered.payload["answerability"]["status"], "conflicting");
375                assert_eq!(
376                    delivered.payload["answerability"]["conflicting"],
377                    serde_json::json!(["port"])
378                );
379                break;
380            }
381        }
382        assert!(found);
383    }
384    #[test]
385    fn serving_metadata_tracks_the_final_budgeted_slice() {
386        use crate::context::ContextBundle;
387        let q = "What are the Orchid gateway port and timeout?";
388        let bundle = ContextBundle {
389            stage: "localization".into(),
390            budget_tokens: 6000,
391            used_tokens: 0,
392            capsules: vec![
393                capsule("Orchid gateway port is 7319.", "port"),
394                capsule("Orchid gateway timeout is 30 seconds.", "timeout"),
395            ],
396            excluded: vec![],
397            skipped: false,
398            top_score: 0.99,
399            top_abs_evidence: 0.99,
400            evidence_coverage: 1.0,
401            uncovered_terms: vec![],
402            chronological: false,
403            known_fact_conflicts: vec![],
404        };
405        let policy = crate::serving::ServingPolicy {
406            explicit_fact_guard: true,
407            ..Default::default()
408        };
409        let full =
410            policy.render_for_query(q, bundle.clone(), true, crate::serving::EVAL_EXPOSURE_ID);
411        assert_eq!(full.payload["answerability"]["status"], "supported");
412        let mut found = false;
413        for budget in (300..2000).step_by(20) {
414            let delivered = crate::serving::ServingPolicy { budget, ..policy }.render_for_query(
415                q,
416                bundle.clone(),
417                true,
418                crate::serving::EVAL_EXPOSURE_ID,
419            );
420            if delivered.capsules.len() == 1 {
421                found = true;
422                assert_eq!(delivered.payload["answerability"]["status"], "partial");
423                assert_eq!(
424                    delivered.payload["answerability"]["missing"],
425                    serde_json::json!(["timeout"])
426                );
427                assert!(
428                    crate::context::delivery::serialized_output_tokens(&delivered.payload)
429                        <= budget
430                );
431                break;
432            }
433        }
434        assert!(found, "expected a budget admitting only the first capsule");
435    }
436    fn claim(
437        subject: &str,
438        environment: Option<&str>,
439        attribute: &str,
440        value: &str,
441    ) -> crate::facts::FactClaim {
442        crate::facts::FactClaim {
443            subject: subject.into(),
444            environment: environment.map(str::to_owned),
445            attribute: attribute.into(),
446            value: value.into(),
447            evidence: "evidence".into(),
448        }
449    }
450    #[test]
451    fn supports_partial_answers_without_borrowing_another_scope() {
452        let request = parse("What are the Orchid staging gateway port and timeout?").unwrap();
453        let port = claim("orchid gateway", Some("staging"), "port", "7319");
454        let wrong_env = claim(
455            "orchid gateway",
456            Some("production"),
457            "timeout",
458            "30 seconds",
459        );
460        let wrong_subject = claim("quartz gateway", Some("staging"), "timeout", "30 seconds");
461        let result = assess(
462            &request,
463            &[
464                (&port, "memory:port"),
465                (&wrong_env, "memory:wrong-env"),
466                (&wrong_subject, "memory:wrong-subject"),
467            ],
468        );
469        assert_eq!(result.status, "partial");
470        assert_eq!(result.missing, ["timeout"]);
471        assert_eq!(result.supported[0].sources, ["memory:port"]);
472    }
473    #[test]
474    fn conflicting_values_are_reported_instead_of_selecting_one() {
475        let request = parse("What is the Orchid gateway port?").unwrap();
476        let a = claim("orchid gateway", None, "port", "7319");
477        let b = claim("orchid gateway", None, "port", "7320");
478        let result = assess(&request, &[(&a, "memory:a"), (&b, "memory:b")]);
479        assert_eq!(result.status, "conflicting");
480        assert_eq!(result.conflicting, ["port"]);
481        assert!(result.supported.is_empty());
482    }
483    #[test]
484    fn parses_shared_subject_and_environment_for_compound_request() {
485        let request = parse("What are the Orchid staging gateway port and timeout?").unwrap();
486        assert_eq!(request.subject, "orchid gateway");
487        assert_eq!(request.environment.as_deref(), Some("staging"));
488        assert_eq!(request.attributes, ["port", "timeout"]);
489    }
490    #[test]
491    fn parses_subject_after_attributes_and_spanish_aliases() {
492        assert_eq!(
493            parse("What is the port for the Quartz gateway?")
494                .unwrap()
495                .subject,
496            "quartz gateway"
497        );
498        assert_eq!(
499            parse("What port does the Quartz gateway use?")
500                .unwrap()
501                .attributes,
502            ["port"]
503        );
504        assert_eq!(
505            parse("¿Cuál es el puerto del Quartz gateway?")
506                .unwrap()
507                .attributes,
508            ["port"]
509        );
510    }
511    #[test]
512    fn broad_or_ambiguous_questions_keep_normal_retrieval() {
513        for query in [
514            "What causes a version conflict?",
515            "What version control system do we use?",
516            "What is the effect of changing port?",
517            "What does `cache.size` control?",
518            "What are the gateway port and database timeout?",
519            "What is the port?",
520        ] {
521            assert_eq!(parse(query), None, "{query}");
522        }
523    }
524}