Skip to main content

kimetsu_brain/
answerability.rs

1//! A bounded, local check for explicit configuration facts, not an entailment model.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum FactEvidence {
4    Unrecognized,
5    ValuePresent,
6    MissingValue,
7}
8struct Rule {
9    query: regex::Regex,
10    value: regex::Regex,
11    secret: bool,
12}
13fn folded(text: &str) -> String {
14    text.to_lowercase()
15        .chars()
16        .map(|c| match c {
17            'á' => 'a',
18            'é' => 'e',
19            'í' => 'i',
20            'ó' => 'o',
21            'ú' | 'ü' => 'u',
22            'ñ' => 'n',
23            _ => c,
24        })
25        .collect()
26}
27fn rules() -> &'static [Rule] {
28    static RULES: std::sync::OnceLock<Vec<Rule>> = std::sync::OnceLock::new();
29    RULES.get_or_init(|| {
30        [
31            (r"\b(?:password|passphrase|contrasena)\b", r#"\b(?:password|passphrase|contrasena)\s*(?:is|es|=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#, true),
32            (r"\b(?:encryption key|clave de cifrado|clave de encriptacion)\b", r#"\b(?:encryption key|clave de cifrado|clave de encriptacion)\s*(?:is|es|=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#, true),
33            (r"\b(?:version|release number)\b", r"\b(?:version|release)\s*(?:is\s+|es\s+|=\s*|:\s*)?v?\d+(?:\.\d+)*\b", false),
34            (r"\b(?:replica|replicas)\b", r"\b(?:\d+\s+(?:production\s+)?replicas?|replicas?\s*(?:count\s*)?(?:is\s+|are\s+|son\s+|=\s*|:\s*)?\d+)\b", false),
35            (r"\b(?:retention|retencion)\b|\b(?:days|dias|weeks|semanas)\b.*\b(?:keep|kept|retain\w*|conserv\w*)\b|\bhow long\b.*\b(?:keep|kept|retain\w*)\b", r"\b(?:retention|retencion|retained|retain|keep|kept|conserv\w*)\b[^.;\n]{0,64}\b\d+\s*(?:seconds?|segundos?|minutes?|minutos?|hours?|horas?|days?|dias?|weeks?|semanas?|months?|meses|years?|anos?)\b", false),
36            (r"\b(?:port|puerto)\b", r"\b(?:port|puerto)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d{1,5}\b", false),
37            (r"\b(?:timeout|tiempo de espera)\b", r"\b(?:timeout|tiempo de espera)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d+(?:\.\d+)?\s*(?:ms|s|seconds?|segundos?|minutes?|minutos?)\b", false),
38            (r"\b(?:retries|retry count|reintentos)\b", r"\b(?:retries|retry count|reintentos)\s*(?:is\s+|are\s+|son\s+|=\s*|:\s*)?\d+\b|\b\d+\s+(?:retries|reintentos)\b", false),
39            (r"\b(?:memory limit|limite de memoria)\b", r"\b(?:memory limit|limite de memoria)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d+\s*(?:kib|mib|gib|kb|mb|gb|bytes)\b", false),
40        ].into_iter().map(|(query,value,secret)| Rule {
41            query: regex::Regex::new(query).expect("constant query pattern"),
42            value: regex::Regex::new(value).expect("constant evidence pattern"), secret,
43        }).collect()
44    })
45}
46fn concrete(value: &str) -> bool {
47    !matches!(
48        value.trim_end_matches('.'),
49        "unknown"
50            | "redacted"
51            | "missing"
52            | "unavailable"
53            | "not"
54            | "stored"
55            | "configured"
56            | "required"
57            | "managed"
58            | "generated"
59            | "hidden"
60            | "provided"
61            | "set"
62            | "secret"
63            | "desconocida"
64            | "desconocido"
65            | "configurada"
66            | "configurado"
67    )
68}
69fn clause_supports(query: &str, body: &str, start: usize, end: usize) -> bool {
70    static ENTITIES: std::sync::OnceLock<Vec<regex::Regex>> = std::sync::OnceLock::new();
71    let entities = ENTITIES.get_or_init(|| {
72        [
73            r"\b(?:database|db|base de datos|sqlite|postgresql|postgres)\b",
74            r"\b(?:gateway|puerta de enlace)\b",
75            r"\b(?:client|cliente)\b",
76            r"\b(?:server|servidor|listener)\b",
77            r"\b(?:logs?|registros)\b",
78            r"\b(?:backups?|copias de seguridad)\b",
79            r"\b(?:workers?|trabajadores)\b",
80            r"\bsqlite\b",
81            r"\b(?:postgres|postgresql)\b",
82            r"\bopenssl\b",
83            r"\bredis\b",
84            r"\bpython\b",
85            r"\bnode\b",
86            r"\brust\b",
87        ]
88        .into_iter()
89        .map(|p| regex::Regex::new(p).unwrap())
90        .collect()
91    });
92    let boundary = |i: usize, c: char| {
93        c == ';'
94            || c == ','
95            || c == '\n'
96            || (c == '.' && body[i + 1..].starts_with(char::is_whitespace))
97    };
98    let left = body[..start]
99        .char_indices()
100        .filter(|(i, c)| boundary(*i, *c))
101        .map(|(i, _)| i + 1)
102        .next_back()
103        .unwrap_or(0);
104    let right = body[end..]
105        .char_indices()
106        .find(|(i, c)| boundary(end + *i, *c))
107        .map(|(i, _)| end + i)
108        .unwrap_or(body.len());
109    let clause = &body[left..right];
110    if [
111        "example",
112        "ejemplo",
113        "hypothetical",
114        "hipotetic",
115        "unknown",
116        "redacted",
117        "not configured",
118        "not installed",
119        "no longer",
120    ]
121    .iter()
122    .any(|word| clause.contains(word))
123    {
124        return false;
125    }
126    // Explicit absence is handled separately; ordinary values under negation
127    // are not affirmative evidence. Keep this conservative within one clause.
128    static NEGATED: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
129    let negated = NEGATED.get_or_init(|| {
130        regex::Regex::new(r"\b(?:not|never|no|nunca|sin|isn't|isnt|don't|doesn't)\b").unwrap()
131    });
132    let matched = &body[start..end];
133    let explicit_absence = matched.contains("no password")
134        || matched.contains("not required")
135        || matched.contains("requiere contrasena");
136    if negated.is_match(clause) && !explicit_absence {
137        return false;
138    }
139    entities
140        .iter()
141        .all(|entity| !entity.is_match(query) || entity.is_match(clause))
142}
143/// Check explicit configuration questions only. Unrecognized questions retain
144/// normal retrieval. ValuePresent means syntactic evidence, not verified truth,
145/// entity identity, freshness or general entailment; other retrieval gates remain.
146pub fn assess(query: &str, text: &str) -> FactEvidence {
147    let q = folded(query);
148    let q = q.trim_start_matches(['¿', ' ', '\t', '\n']);
149    if ![
150        "what ",
151        "what's ",
152        "which ",
153        "how many ",
154        "how much ",
155        "how long ",
156        "que ",
157        "cual ",
158        "cuantos ",
159        "cuantas ",
160        "cuanto ",
161        "tell me ",
162        "dime ",
163    ]
164    .iter()
165    .any(|prefix| q.starts_with(prefix))
166    {
167        return FactEvidence::Unrecognized;
168    }
169    if q.split_whitespace()
170        .any(|w| matches!(w, "should" | "could" | "would" | "deberia" | "debo"))
171    {
172        return FactEvidence::Unrecognized;
173    }
174    let normalized = folded(text);
175    let mut body = normalized.as_str();
176    if let Some((prefix, rest)) = body.split_once(" - ") {
177        if prefix.contains(':') && !prefix.contains(' ') {
178            body = rest;
179        }
180    }
181    while body.starts_with('[') {
182        if let Some((_, rest)) = body.split_once(']') {
183            body = rest.trim_start();
184        } else {
185            break;
186        }
187    }
188    // Literal keys are only gated for direct value questions with one target.
189    // Explanatory and coordinated key questions retain normal retrieval.
190    if q.contains('`')
191        && (q.matches('`').count() != 2
192            || !["what is ", "what's ", "cual es ", "que valor "]
193                .iter()
194                .any(|p| q.starts_with(p)))
195    {
196        return FactEvidence::Unrecognized;
197    }
198    // Exact literal config keys generalize beyond the curated natural-language
199    // attributes. Do not treat a different key's value as an answer.
200    if let Some(key) = q.split('`').nth(1).filter(|key| {
201        key.contains(['.', '_'])
202            && key
203                .chars()
204                .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
205    }) {
206        static ASSIGNMENT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
207        let pattern = ASSIGNMENT.get_or_init(|| {
208            regex::Regex::new(
209                r#"\b(?P<key>[a-z_][a-z0-9_.-]*)`?\s*(?:=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#,
210            )
211            .unwrap()
212        });
213        return if pattern.captures_iter(body).any(|c| {
214            &c["key"] == key
215                && concrete(&c["value"])
216                && clause_supports(q, body, c.get(0).unwrap().start(), c.get(0).unwrap().end())
217        }) {
218            FactEvidence::ValuePresent
219        } else {
220            FactEvidence::MissingValue
221        };
222    }
223    // A single-attribute guard cannot adjudicate compound questions. Leave
224    // those to normal retrieval so complementary capsules remain available.
225    let matches: Vec<_> = rules()
226        .iter()
227        .filter_map(|r| r.query.find(q).map(|m| (m.start(), r)))
228        .collect();
229    if matches.len() != 1 {
230        return FactEvidence::Unrecognized;
231    }
232    let (offset, rule) = matches[0];
233    // Only accept a constrained noun phrase before the attribute. Unknown
234    // wording passes through rather than suppressing troubleshooting evidence.
235    let attribute = rule.query.find(q).unwrap();
236    let suffix = q[attribute.end()..].trim_start();
237    if [
238        "control",
239        "hashing",
240        "policy",
241        "rotation",
242        "conflict",
243        "file",
244        "algorithm",
245        "management",
246    ]
247    .iter()
248    .any(|word| suffix.starts_with(word))
249    {
250        return FactEvidence::Unrecognized;
251    }
252    let prefix = &q[..offset];
253    let proper_names: Vec<_> = query
254        .split_whitespace()
255        .filter(|w| w.chars().next().is_some_and(char::is_uppercase))
256        .map(folded)
257        .collect();
258    if !prefix.split_whitespace().all(|word| {
259        matches!(
260            word,
261            "what"
262                | "what's"
263                | "which"
264                | "is"
265                | "are"
266                | "the"
267                | "a"
268                | "an"
269                | "how"
270                | "many"
271                | "much"
272                | "long"
273                | "que"
274                | "cual"
275                | "es"
276                | "la"
277                | "el"
278                | "cuantos"
279                | "cuantas"
280                | "cuanto"
281                | "tell"
282                | "me"
283                | "dime"
284                | "current"
285                | "configured"
286                | "required"
287                | "authentication"
288                | "request"
289                | "tcp"
290                | "http"
291                | "database"
292                | "db"
293                | "sqlite"
294                | "postgresql"
295                | "postgres"
296                | "gateway"
297                | "client"
298                | "server"
299                | "listener"
300                | "worker"
301                | "memory"
302                | "production"
303                | "staging"
304                | "backup"
305                | "log"
306                | "logs"
307                | "base"
308                | "de"
309                | "datos"
310                | "del"
311        ) || proper_names.iter().any(|name| name == word)
312    }) {
313        return FactEvidence::Unrecognized;
314    }
315    if rule.secret {
316        static ABSENT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
317        let absent = ABSENT.get_or_init(|| regex::Regex::new(r"\b(?:no password (?:is )?required|password (?:is )?not required|no (?:se )?requiere contrasena)\b").unwrap());
318        if rule.query.as_str().contains("password")
319            && absent
320                .find_iter(body)
321                .any(|m| clause_supports(q, body, m.start(), m.end()))
322        {
323            return FactEvidence::ValuePresent;
324        }
325    }
326    if rule.value.captures_iter(body).any(|c| {
327        (!rule.secret || concrete(&c["value"]))
328            && clause_supports(q, body, c.get(0).unwrap().start(), c.get(0).unwrap().end())
329    }) {
330        FactEvidence::ValuePresent
331    } else {
332        FactEvidence::MissingValue
333    }
334}
335
336/// Apply the same explicit-fact policy to MCP and the lightweight hook.
337pub fn filter_bundle(query: &str, bundle: &mut crate::context::ContextBundle) {
338    let request = crate::fact_query::parse(query);
339    for capsule in std::mem::take(&mut bundle.capsules) {
340        let rejected = if let Some(request) = request.as_ref().filter(|_| !capsule.facts.is_empty())
341        {
342            !capsule.facts.iter().any(|fact| {
343                crate::fact_query::visible(&capsule, fact)
344                    && crate::fact_query::matches(request, &fact.claim)
345            })
346        } else {
347            assess(query, &capsule.summary) == FactEvidence::MissingValue
348        };
349        if rejected {
350            bundle.excluded.push(capsule);
351        } else {
352            bundle.capsules.push(capsule);
353        }
354    }
355    bundle.used_tokens = bundle.capsules.iter().map(|c| c.token_estimate).sum();
356    bundle.top_score = bundle
357        .capsules
358        .iter()
359        .map(|c| c.score)
360        .fold(0.0_f32, f32::max);
361    if bundle.capsules.is_empty() {
362        bundle.skipped = true;
363        bundle.evidence_coverage = 0.0;
364    }
365}
366/// Compression must not hide the value used for admission. Final byte budgeting
367/// still decides whether the complete evidence fits.
368pub fn compress_preserving_evidence(query: &str, summary: &str, sentences: usize) -> String {
369    let short = crate::context::compress_for_render(summary, sentences);
370    if assess(query, summary) == FactEvidence::ValuePresent
371        && assess(query, &short) == FactEvidence::MissingValue
372    {
373        summary.to_owned()
374    } else {
375        short
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    #[test]
383    fn related_topics_do_not_supply_missing_configuration_values() {
384        for (q, text) in [
385            (
386                "What password does the staging listener require?",
387                "The staging listener binds port 6319.",
388            ),
389            (
390                "¿Qué versión de SQLite requiere el proyecto?",
391                "The project uses SQLite WAL mode.",
392            ),
393            (
394                "How many replicas run in production?",
395                "Production runs in eu-north-1.",
396            ),
397            (
398                "¿Cuántos días se conservan las copias?",
399                "Backups run daily at 02:40 UTC.",
400            ),
401            (
402                "What is the encryption key?",
403                "Encryption is enabled for the database.",
404            ),
405            (
406                "What is the request timeout?",
407                "Requests are logged every 30 seconds.",
408            ),
409            ("What is `cache.max_entries`?", "cache.min_entries = 200"),
410        ] {
411            assert_eq!(assess(q, text), FactEvidence::MissingValue, "{q}");
412        }
413    }
414    #[test]
415    fn explicit_values_survive_in_both_languages() {
416        for (q, text) in [
417            (
418                "What password is configured?",
419                "password = `sample-only-value`",
420            ),
421            (
422                "¿Qué versión de SQLite requiere el proyecto?",
423                "SQLite version 3.46 is required.",
424            ),
425            (
426                "How many replicas run in production?",
427                "Production runs 4 replicas.",
428            ),
429            (
430                "¿Cuántos días se conservan las copias?",
431                "Backups are retained for 21 days.",
432            ),
433            (
434                "What is the encryption key?",
435                "encryption key = `sample-only-key`",
436            ),
437            (
438                "What is the request timeout?",
439                "The request timeout is 30 seconds.",
440            ),
441            ("What is `cache.max_entries`?", "cache.max_entries = 200"),
442            (
443                "Which TCP port is configured?",
444                "The listener binds TCP port 6319.",
445            ),
446        ] {
447            assert_eq!(assess(q, text), FactEvidence::ValuePresent, "{q}");
448        }
449    }
450    #[test]
451    fn mentions_and_unknown_values_are_not_answers() {
452        for text in [
453            "The password is unknown.",
454            "Set a password before starting.",
455            "password = [REDACTED]",
456            "[tags: password] The listener port is 6319.",
457        ] {
458            assert_eq!(
459                assess("What is the password?", text),
460                FactEvidence::MissingValue,
461                "{text}"
462            );
463        }
464    }
465    #[test]
466    fn explicit_absence_and_short_retention_are_useful_answers() {
467        assert_eq!(
468            assess(
469                "What password is required?",
470                "No password is required for this listener."
471            ),
472            FactEvidence::ValuePresent
473        );
474        assert_eq!(
475            assess("How long are logs kept?", "Logs are kept for 12 hours."),
476            FactEvidence::ValuePresent
477        );
478        assert_eq!(
479            assess(
480                "What should I do about a version conflict?",
481                "Read the migration notes."
482            ),
483            FactEvidence::Unrecognized
484        );
485    }
486    #[test]
487    fn a_value_for_another_component_is_not_an_answer() {
488        for (q, text) in [
489            (
490                "What is the database timeout?",
491                "Gateway timeout is 30 seconds. The database stores state.",
492            ),
493            (
494                "What is the SQLite version?",
495                "PostgreSQL version 16 is installed.",
496            ),
497            (
498                "¿Qué contraseña requiere la base de datos?",
499                "The gateway password is `example-value-only`.",
500            ),
501            (
502                "How long are logs retained?",
503                "Backups are retained for 21 days.",
504            ),
505            (
506                "What is the password?",
507                "Example: password = `demo-only-value`",
508            ),
509        ] {
510            assert_eq!(assess(q, text), FactEvidence::MissingValue, "{q}");
511        }
512    }
513    #[test]
514    fn review_scope_regressions() {
515        for q in [
516            "What causes a version conflict?",
517            "What version control system do we use?",
518            "What password hashing algorithm do we use?",
519            "What does `cache.max_entries` control?",
520            "What are `cache.max_entries` and `cache.ttl`?",
521            "Which files configure the port?",
522            "What are the database port and password?",
523        ] {
524            assert_eq!(
525                assess(q, "See configuration notes."),
526                FactEvidence::Unrecognized,
527                "{q}"
528            );
529        }
530    }
531    #[test]
532    fn review_negated_values() {
533        for text in [
534            "We do not use SQLite version 3.45.",
535            "No usamos SQLite version 3.45.",
536        ] {
537            assert_eq!(
538                assess("What is the SQLite version?", text),
539                FactEvidence::MissingValue,
540                "{text}"
541            );
542        }
543    }
544    #[test]
545    fn review_competing_clause() {
546        assert_eq!(
547            assess(
548                "What is the database timeout?",
549                "Gateway timeout is 30 seconds, while the database stores state."
550            ),
551            FactEvidence::MissingValue
552        );
553    }
554    #[test]
555    fn broad_tasks_remain_outside_this_bounded_guard() {
556        for q in [
557            "How do I configure passwords safely?",
558            "Fix the SQLite migration failure",
559            "Explain memory retrieval",
560            "Why does my test hang?",
561        ] {
562            assert_eq!(
563                assess(q, "Relevant troubleshooting advice."),
564                FactEvidence::Unrecognized
565            );
566        }
567    }
568}