Skip to main content

reddb_types/
knowledge.rs

1//! Agent-facing type & multi-model knowledge reference — generated from the
2//! engine's own type-system authorities (ADR 0061, "Agent-Facing Knowledge &
3//! MCP Surface").
4//!
5//! Where [`crate::knowledge`]'s sibling in `reddb-io-rql` emits the RQL grammar
6//! surface, this module emits the **value-type catalog** and the **multi-model
7//! map**. The volatile facts come straight from the source of truth so the
8//! reference cannot drift from the engine:
9//!
10//! - the value types come from the three type-system authorities in this crate
11//!   — [`function_catalog::FUNCTION_CATALOG`], [`operator_catalog::OPERATOR_CATALOG`],
12//!   and [`cast_catalog::CAST_CATALOG`] (every concrete type they reference), and
13//! - the operators and casts are likewise read live from those tables.
14//!
15//! Layered over that per-model type catalog is the **multi-model map**: the
16//! paradigms RedDB stores (documents, key-value, queues, graph nodes/edges,
17//! vault secrets, config, and RQL-tabular collections). The map is the
18//! conceptual narrative — hand-authored because it is judgment, not extractable
19//! — but it points into the generated type catalog by [`TypeCategory`], so the
20//! two layers stay coupled.
21//!
22//! Nothing here hand-maintains *which* value types exist — that is read from
23//! the catalogs above. The same generated content is served two ways from this
24//! one source: as the `reddb://knowledge/types` MCP resource and as the type
25//! section of the generated `docs/llms.txt`. The anti-drift tests at the bottom
26//! pin the "generated, exhaustive, in-sync" contract.
27
28use crate::cast_catalog::{CastContext, CAST_CATALOG};
29use crate::function_catalog::FUNCTION_CATALOG;
30use crate::json::{Map, Value as JsonValue};
31use crate::operator_catalog::{OperatorKind, OPERATOR_CATALOG};
32use crate::types::{DataType, TypeCategory};
33
34/// Canonical URI for the type & multi-model knowledge resource served over MCP.
35pub const RESOURCE_URI: &str = "reddb://knowledge/types";
36
37/// Short human title for the type knowledge resource.
38pub const RESOURCE_TITLE: &str = "RedDB Type & Multi-Model Reference";
39
40/// One-line description of the type knowledge resource.
41pub const RESOURCE_DESCRIPTION: &str =
42    "Generated value-type catalog (function/operator/cast authorities) plus the multi-model map.";
43
44/// Markers delimiting the generated type block inside `docs/llms.txt`. The
45/// `docs/llms.txt` sync test reads the text between these markers and asserts
46/// it equals [`type_reference_markdown`], so the file is generated, not
47/// hand-maintained.
48pub const LLMS_BEGIN_MARKER: &str = "<!-- BEGIN GENERATED: types -->";
49/// Closing marker for the generated type block in `docs/llms.txt`.
50pub const LLMS_END_MARKER: &str = "<!-- END GENERATED: types -->";
51
52/// One paradigm in RedDB's multi-model surface. The narrative (`summary`) is
53/// hand-authored judgment; `categories` points into the generated value-type
54/// catalog so the map is layered over — not duplicated from — the type system.
55pub struct ModelParadigm {
56    /// Display name of the paradigm (e.g. "Documents", "Graph nodes & edges").
57    pub name: &'static str,
58    /// One-sentence description of what the paradigm stores and how it is shaped.
59    pub summary: &'static str,
60    /// The [`TypeCategory`] families this paradigm predominantly holds, linking
61    /// the map back into the per-model type catalog above it.
62    pub categories: &'static [TypeCategory],
63}
64
65/// The multi-model map: the paradigms RedDB stores, layered over the value-type
66/// catalog. Hand-authored narrative (ADR 0061 §3), but each entry references the
67/// generated [`TypeCategory`] families it predominantly holds.
68pub const MULTI_MODEL_MAP: &[ModelParadigm] = &[
69    ModelParadigm {
70        name: "Documents",
71        summary: "Schemaless JSON-shaped entities addressed by collection + entity id; \
72nested fields are typed value-by-value from the catalog below.",
73        categories: &[
74            TypeCategory::Json,
75            TypeCategory::String,
76            TypeCategory::Numeric,
77            TypeCategory::Boolean,
78        ],
79    },
80    ModelParadigm {
81        name: "Key-value",
82        summary: "Flat collection + key → value pairs for caches and counters; any \
83catalogued value type can be the stored payload.",
84        categories: &[
85            TypeCategory::String,
86            TypeCategory::Numeric,
87            TypeCategory::Json,
88        ],
89    },
90    ModelParadigm {
91        name: "Queues",
92        summary: "Ordered FIFO/priority message streams (LPUSH/RPUSH/LPOP/RPOP, ACK/NACK); \
93each message body is a catalogued value, usually text or JSON.",
94        categories: &[
95            TypeCategory::String,
96            TypeCategory::Json,
97            TypeCategory::TimeSpan,
98        ],
99    },
100    ModelParadigm {
101        name: "Graph nodes & edges",
102        summary: "Property graph of nodes and edges; references between them are first-class \
103value types and properties draw from the full catalog.",
104        categories: &[
105            TypeCategory::Reference,
106            TypeCategory::String,
107            TypeCategory::Numeric,
108        ],
109    },
110    ModelParadigm {
111        name: "Vault secrets",
112        summary: "Encrypted secrets and password hashes that the expression layer treats as \
113opaque — coercion must be opted into explicitly.",
114        categories: &[TypeCategory::Opaque, TypeCategory::String],
115    },
116    ModelParadigm {
117        name: "Config",
118        summary: "Hierarchical, resolvable configuration entries; values are catalogued \
119scalars and JSON resolved per environment.",
120        categories: &[
121            TypeCategory::String,
122            TypeCategory::Boolean,
123            TypeCategory::Numeric,
124            TypeCategory::Json,
125        ],
126    },
127    ModelParadigm {
128        name: "RQL-tabular",
129        summary: "Relational tables with typed columns queried through RQL; columns bind \
130directly to the value types and categories of the catalog below.",
131        categories: &[
132            TypeCategory::Numeric,
133            TypeCategory::String,
134            TypeCategory::Boolean,
135            TypeCategory::DateTime,
136            TypeCategory::Domain,
137        ],
138    },
139];
140
141/// Push a concrete value type into `acc`, skipping the catalog sentinels.
142///
143/// `DataType::Unknown` (the function-catalog "any" placeholder) and
144/// `DataType::Nullable` (the operator-catalog prefix "don't care" marker) are
145/// matching markers in the authorities, not real value types, so they are
146/// filtered out of the published catalog.
147fn push_value_type(acc: &mut Vec<DataType>, candidate: DataType) {
148    if matches!(candidate, DataType::Unknown | DataType::Nullable) {
149        return;
150    }
151    if !acc.contains(&candidate) {
152        acc.push(candidate);
153    }
154}
155
156/// Every concrete value type referenced by the type-system authorities
157/// (function / operator / cast catalogs), sorted by discriminant and
158/// deduplicated.
159///
160/// This is read live from the three catalogs, so adding a type to any of them
161/// flows automatically into the generated reference; the anti-drift test
162/// [`tests::catalog_matches_authorities`] independently re-derives the same set
163/// and pins the equality.
164pub fn catalogued_value_types() -> Vec<DataType> {
165    let mut types: Vec<DataType> = Vec::new();
166    for entry in FUNCTION_CATALOG {
167        for &arg in entry.arg_types {
168            push_value_type(&mut types, arg);
169        }
170        push_value_type(&mut types, entry.return_type);
171    }
172    for entry in OPERATOR_CATALOG {
173        push_value_type(&mut types, entry.lhs_type);
174        push_value_type(&mut types, entry.rhs_type);
175        push_value_type(&mut types, entry.return_type);
176    }
177    for entry in CAST_CATALOG {
178        push_value_type(&mut types, entry.src);
179        push_value_type(&mut types, entry.target);
180    }
181    types.sort_by_key(|ty| *ty as u8);
182    types
183}
184
185/// Distinct operator symbols, sorted, taken from the engine's static
186/// [`OPERATOR_CATALOG`]. The catalog carries one row per overload, so a symbol
187/// (e.g. `-`, which is both infix and prefix) can appear several times — this
188/// collapses them.
189pub fn operator_symbols() -> Vec<&'static str> {
190    let mut names: Vec<&'static str> = OPERATOR_CATALOG.iter().map(|entry| entry.name).collect();
191    names.sort_unstable();
192    names.dedup();
193    names
194}
195
196/// The implicit (always-allowed, lossless) casts the engine inserts silently,
197/// sorted by `(src, target)` discriminant. These are the widenings an agent can
198/// rely on without writing `CAST(...)`.
199pub fn implicit_casts() -> Vec<(DataType, DataType)> {
200    let mut pairs: Vec<(DataType, DataType)> = CAST_CATALOG
201        .iter()
202        .filter(|cast| cast.context == CastContext::Implicit)
203        .map(|cast| (cast.src, cast.target))
204        .collect();
205    pairs.sort_by_key(|(src, target)| (*src as u8, *target as u8));
206    pairs
207}
208
209/// Human label for a [`TypeCategory`], used by the generated map.
210fn category_label(category: TypeCategory) -> &'static str {
211    match category {
212        TypeCategory::Numeric => "Numeric",
213        TypeCategory::String => "String",
214        TypeCategory::Boolean => "Boolean",
215        TypeCategory::DateTime => "DateTime",
216        TypeCategory::TimeSpan => "TimeSpan",
217        TypeCategory::Array => "Array",
218        TypeCategory::Network => "Network",
219        TypeCategory::Geo => "Geo",
220        TypeCategory::Domain => "Domain",
221        TypeCategory::Uuid => "Uuid",
222        TypeCategory::Opaque => "Opaque",
223        TypeCategory::Reference => "Reference",
224        TypeCategory::Vector => "Vector",
225        TypeCategory::Json => "Json",
226        TypeCategory::Unknown => "Unknown",
227    }
228}
229
230/// The order in which value-type categories are presented in the reference.
231const CATEGORY_ORDER: &[TypeCategory] = &[
232    TypeCategory::Numeric,
233    TypeCategory::String,
234    TypeCategory::Boolean,
235    TypeCategory::DateTime,
236    TypeCategory::TimeSpan,
237    TypeCategory::Domain,
238    TypeCategory::Network,
239    TypeCategory::Geo,
240    TypeCategory::Uuid,
241    TypeCategory::Json,
242    TypeCategory::Vector,
243    TypeCategory::Array,
244    TypeCategory::Reference,
245    TypeCategory::Opaque,
246];
247
248fn render_code_list<I, S>(items: I) -> String
249where
250    I: IntoIterator<Item = S>,
251    S: AsRef<str>,
252{
253    items
254        .into_iter()
255        .map(|item| format!("`{}`", item.as_ref()))
256        .collect::<Vec<_>>()
257        .join(", ")
258}
259
260/// Generate the canonical type & multi-model reference as Markdown, sourced
261/// entirely from the engine's type-system authorities (for the type catalog)
262/// plus the hand-authored multi-model narrative. This single string is what the
263/// MCP `reddb://knowledge/types` resource serves and what `docs/llms.txt` embeds.
264pub fn type_reference_markdown() -> String {
265    let types = catalogued_value_types();
266    let operators = operator_symbols();
267    let casts = implicit_casts();
268
269    let mut out = String::new();
270    out.push_str("# RedDB Type & Multi-Model Reference\n\n");
271    out.push_str(
272        "RedDB is a multi-model store (documents, key-value, queues, graph, vault, \
273config, and RQL-tabular collections) layered over one logical type system.\n\n",
274    );
275    out.push_str(
276        "This reference is generated from the `reddb-io-types` function, operator, and \
277cast catalogs. Do not edit by hand — regenerate from the engine.\n\n",
278    );
279
280    // ── Value types, grouped by category ──
281    out.push_str(&format!("## Value types ({})\n\n", types.len()));
282    out.push_str(
283        "Every concrete value type the engine's type-system authorities reference, \
284grouped by coercion category:\n\n",
285    );
286    for &category in CATEGORY_ORDER {
287        let mut names: Vec<String> = types
288            .iter()
289            .filter(|ty| ty.category() == category)
290            .map(|ty| ty.to_string())
291            .collect();
292        names.dedup();
293        if names.is_empty() {
294            continue;
295        }
296        out.push_str(&format!("### {} types\n\n", category_label(category)));
297        out.push_str(&render_code_list(&names));
298        out.push_str("\n\n");
299    }
300
301    // ── Operators ──
302    out.push_str(&format!("## Operators ({})\n\n", operators.len()));
303    out.push_str("The type system resolves these built-in operators:\n\n");
304    out.push_str(&render_code_list(&operators));
305    out.push_str("\n\n");
306
307    // ── Implicit casts ──
308    out.push_str(&format!("## Implicit casts ({})\n\n", casts.len()));
309    out.push_str(
310        "Lossless widenings the engine inserts silently — usable anywhere without an \
311explicit `CAST`:\n\n",
312    );
313    for (src, target) in &casts {
314        out.push_str(&format!("- `{src}` → `{target}`\n"));
315    }
316    out.push('\n');
317
318    // ── Multi-model map, layered over the type catalog ──
319    out.push_str("## Multi-model map\n\n");
320    out.push_str(
321        "RedDB stores several paradigms over the value-type catalog above. Each \
322paradigm's `Type families` point back into that catalog by category:\n\n",
323    );
324    for paradigm in MULTI_MODEL_MAP {
325        out.push_str(&format!("### {}\n\n", paradigm.name));
326        out.push_str(paradigm.summary);
327        out.push_str("\n\n");
328        let families: Vec<&str> = paradigm
329            .categories
330            .iter()
331            .map(|&category| category_label(category))
332            .collect();
333        out.push_str(&format!(
334            "Type families: {}\n\n",
335            render_code_list(&families)
336        ));
337    }
338
339    // Trim the trailing blank line so the body ends with exactly one newline.
340    while out.ends_with("\n\n") {
341        out.pop();
342    }
343    out
344}
345
346/// The type block as embedded in `docs/llms.txt`: the generated reference fenced
347/// by the begin/end markers. Emitting the markers here keeps `docs/llms.txt` and
348/// the MCP resource fed by one source.
349pub fn type_llms_section() -> String {
350    format!(
351        "{begin}\n{body}\n{end}",
352        begin = LLMS_BEGIN_MARKER,
353        body = type_reference_markdown(),
354        end = LLMS_END_MARKER,
355    )
356}
357
358// ─────────────────────────────────────────────────────────────────────────
359// Active type/cast lookup (ADR 0061 §4 — the `reddb_type_of` MCP tool seam)
360//
361// Where the markdown reference above is the *passive* knowledge resource, the
362// functions below answer a *targeted* question — "what is this value or type
363// name, and what can I cast/operate it to?" — by reading the same three
364// catalogs live. The MCP `reddb_type_of` tool is a thin wrapper over
365// [`type_of_json`]: it owns no type knowledge of its own.
366// ─────────────────────────────────────────────────────────────────────────
367
368/// Resolve a type *name* (canonical reddb spelling or a common SQL alias, both
369/// case-insensitive — e.g. `INTEGER`, `int`, `string`) to its [`DataType`].
370///
371/// Delegates to [`DataType::from_sql_name`] so the accepted spellings stay in
372/// lockstep with the engine's own SQL type parser; returns `None` for names the
373/// engine does not recognise.
374pub fn resolve_type_name(name: &str) -> Option<DataType> {
375    DataType::from_sql_name(name)
376}
377
378/// Infer the [`DataType`] of a bare JSON literal as the engine would type it on
379/// the wire: `null → NULLABLE`, booleans → `BOOLEAN`, integral numbers →
380/// `INTEGER`, fractional numbers → `FLOAT`, strings → `TEXT`, arrays → `ARRAY`,
381/// objects → `JSON`.
382///
383/// This is deliberately the *structural* type of the literal, not a re-parse
384/// into a richer domain type (an email-shaped string is still `TEXT` here);
385/// callers wanting the domain type pass the type name explicitly.
386pub fn infer_literal_type(value: &JsonValue) -> DataType {
387    match value {
388        JsonValue::Null => DataType::Nullable,
389        JsonValue::Bool(_) => DataType::Boolean,
390        JsonValue::Integer(_) => DataType::Integer,
391        JsonValue::Number(n) => {
392            if n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
393                DataType::Integer
394            } else {
395                DataType::Float
396            }
397        }
398        JsonValue::Decimal(_) => DataType::DecimalText,
399        JsonValue::String(_) => DataType::Text,
400        JsonValue::Array(_) => DataType::Array,
401        JsonValue::Object(_) => DataType::Json,
402    }
403}
404
405/// Human label for a [`CastContext`], matching the engine's coercion vocabulary.
406fn cast_context_label(context: CastContext) -> &'static str {
407    match context {
408        CastContext::Implicit => "implicit",
409        CastContext::Assignment => "assignment",
410        CastContext::Explicit => "explicit",
411    }
412}
413
414/// Human label for an [`OperatorKind`].
415fn operator_kind_label(kind: OperatorKind) -> &'static str {
416    match kind {
417        OperatorKind::Infix => "infix",
418        OperatorKind::Prefix => "prefix",
419        OperatorKind::Postfix => "postfix",
420    }
421}
422
423/// Every cast *out of* `ty` registered in [`CAST_CATALOG`], in catalog order.
424/// Each tuple is `(target, context, lossy)` read straight from the catalog row.
425pub fn casts_from(ty: DataType) -> Vec<(DataType, CastContext, bool)> {
426    CAST_CATALOG
427        .iter()
428        .filter(|cast| cast.src == ty)
429        .map(|cast| (cast.target, cast.context, cast.lossy))
430        .collect()
431}
432
433/// Every operator overload in [`OPERATOR_CATALOG`] that accepts `ty` as one of
434/// its operands, in catalog order. The `Nullable` left-operand marker on prefix
435/// operators is treated as "no left operand", so a prefix `-` matches only when
436/// `ty` is its (right) operand, never via the marker.
437pub fn operators_for(ty: DataType) -> Vec<&'static crate::operator_catalog::OperatorEntry> {
438    OPERATOR_CATALOG
439        .iter()
440        .filter(|entry| {
441            let lhs_matches = entry.kind != OperatorKind::Prefix && entry.lhs_type == ty;
442            lhs_matches || entry.rhs_type == ty
443        })
444        .collect()
445}
446
447/// Build the structured `reddb_type_of` answer for a resolved [`DataType`]: its
448/// canonical name + category, the casts available out of it, and the operator
449/// overloads that accept it — all read live from the catalogs.
450pub fn type_facts_json(ty: DataType) -> JsonValue {
451    let casts: Vec<JsonValue> = casts_from(ty)
452        .into_iter()
453        .map(|(target, context, lossy)| {
454            let mut obj = Map::new();
455            obj.insert("target".to_string(), JsonValue::String(target.to_string()));
456            obj.insert(
457                "context".to_string(),
458                JsonValue::String(cast_context_label(context).to_string()),
459            );
460            obj.insert("lossy".to_string(), JsonValue::Bool(lossy));
461            JsonValue::Object(obj)
462        })
463        .collect();
464
465    let operators: Vec<JsonValue> = operators_for(ty)
466        .into_iter()
467        .map(|entry| {
468            let mut obj = Map::new();
469            obj.insert(
470                "symbol".to_string(),
471                JsonValue::String(entry.name.to_string()),
472            );
473            obj.insert(
474                "kind".to_string(),
475                JsonValue::String(operator_kind_label(entry.kind).to_string()),
476            );
477            // Prefix operators carry a `Nullable` left-operand marker, not a real
478            // operand — surface that as JSON null rather than leaking the marker.
479            let lhs = if entry.kind == OperatorKind::Prefix {
480                JsonValue::Null
481            } else {
482                JsonValue::String(entry.lhs_type.to_string())
483            };
484            obj.insert("lhs".to_string(), lhs);
485            obj.insert(
486                "rhs".to_string(),
487                JsonValue::String(entry.rhs_type.to_string()),
488            );
489            obj.insert(
490                "returns".to_string(),
491                JsonValue::String(entry.return_type.to_string()),
492            );
493            JsonValue::Object(obj)
494        })
495        .collect();
496
497    let mut obj = Map::new();
498    obj.insert(
499        "canonical_type".to_string(),
500        JsonValue::String(ty.to_string()),
501    );
502    obj.insert(
503        "category".to_string(),
504        JsonValue::String(category_label(ty.category()).to_string()),
505    );
506    obj.insert(
507        "is_preferred".to_string(),
508        JsonValue::Bool(ty.is_preferred()),
509    );
510    obj.insert("casts".to_string(), JsonValue::Array(casts));
511    obj.insert("operators".to_string(), JsonValue::Array(operators));
512    JsonValue::Object(obj)
513}
514
515/// Answer a `reddb_type_of` query. Exactly one of `type_name` or `value` should
516/// carry the lookup subject: a type name (resolved with [`resolve_type_name`])
517/// or a JSON literal whose structural type is inferred with
518/// [`infer_literal_type`]. Returns the structured facts from [`type_facts_json`]
519/// wrapped with the resolved canonical type, or `None` when a supplied type name
520/// is unknown to the engine.
521pub fn type_of_json(type_name: Option<&str>, value: Option<&JsonValue>) -> Option<JsonValue> {
522    let ty = match (type_name, value) {
523        (Some(name), _) => resolve_type_name(name)?,
524        (None, Some(literal)) => infer_literal_type(literal),
525        (None, None) => return None,
526    };
527    Some(type_facts_json(ty))
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    /// Independently re-derive the concrete value types from the three
535    /// authorities and assert the published catalog equals that set — the
536    /// anti-drift guarantee: the reference stays in sync with the
537    /// function/operator/cast catalogs.
538    #[test]
539    fn catalog_matches_authorities() {
540        let mut expected: Vec<DataType> = Vec::new();
541        let mut record = |ty: DataType| {
542            if matches!(ty, DataType::Unknown | DataType::Nullable) {
543                return;
544            }
545            if !expected.contains(&ty) {
546                expected.push(ty);
547            }
548        };
549        for entry in FUNCTION_CATALOG {
550            for &arg in entry.arg_types {
551                record(arg);
552            }
553            record(entry.return_type);
554        }
555        for entry in OPERATOR_CATALOG {
556            record(entry.lhs_type);
557            record(entry.rhs_type);
558            record(entry.return_type);
559        }
560        for entry in CAST_CATALOG {
561            record(entry.src);
562            record(entry.target);
563        }
564        expected.sort_by_key(|ty| *ty as u8);
565
566        assert_eq!(
567            catalogued_value_types(),
568            expected,
569            "the published value-type catalog drifted from the function/operator/cast \
570authorities in reddb-io-types"
571        );
572    }
573
574    /// The published catalog excludes the catalog sentinels and is non-empty.
575    #[test]
576    fn catalog_excludes_sentinels() {
577        let types = catalogued_value_types();
578        assert!(!types.is_empty(), "value-type catalog must not be empty");
579        assert!(
580            !types.contains(&DataType::Unknown),
581            "Unknown is a catalog placeholder, not a value type"
582        );
583        assert!(
584            !types.contains(&DataType::Nullable),
585            "Nullable is a prefix-operator marker, not a value type"
586        );
587    }
588
589    /// The catalog is sorted by discriminant, so the generated reference is
590    /// stable and reviewable.
591    #[test]
592    fn catalog_is_sorted_and_unique() {
593        let types = catalogued_value_types();
594        let mut sorted = types.clone();
595        sorted.sort_by_key(|ty| *ty as u8);
596        assert_eq!(types, sorted, "catalogued_value_types must be sorted");
597        let mut deduped = types.clone();
598        deduped.dedup();
599        assert_eq!(
600            deduped.len(),
601            types.len(),
602            "catalog must not contain duplicates"
603        );
604    }
605
606    /// authorities ⊆ reference: every catalogued value type appears, by its
607    /// display name, in the generated reference.
608    #[test]
609    fn reference_lists_every_value_type() {
610        let reference = type_reference_markdown();
611        for ty in catalogued_value_types() {
612            assert!(
613                reference.contains(&format!("`{ty}`")),
614                "value type {ty} from the catalogs is missing from the generated type \
615reference"
616            );
617        }
618    }
619
620    /// Every operator symbol from the catalog appears in the reference.
621    #[test]
622    fn reference_lists_every_operator() {
623        let reference = type_reference_markdown();
624        for symbol in operator_symbols() {
625            assert!(
626                reference.contains(&format!("`{symbol}`")),
627                "operator {symbol:?} is missing from the generated type reference"
628            );
629        }
630    }
631
632    /// The multi-model map enumerates all seven paradigms RedDB stores, and each
633    /// is layered over the type catalog by at least one category family.
634    #[test]
635    fn multi_model_map_covers_every_paradigm() {
636        let names: Vec<&str> = MULTI_MODEL_MAP.iter().map(|m| m.name).collect();
637        for expected in [
638            "Documents",
639            "Key-value",
640            "Queues",
641            "Graph nodes & edges",
642            "Vault secrets",
643            "Config",
644            "RQL-tabular",
645        ] {
646            assert!(
647                names.contains(&expected),
648                "multi-model map is missing the {expected:?} paradigm"
649            );
650        }
651        for paradigm in MULTI_MODEL_MAP {
652            assert!(
653                !paradigm.categories.is_empty(),
654                "paradigm {:?} must link to at least one type category",
655                paradigm.name
656            );
657        }
658    }
659
660    /// Every paradigm and its category families render into the reference.
661    #[test]
662    fn reference_includes_multi_model_map() {
663        let reference = type_reference_markdown();
664        for paradigm in MULTI_MODEL_MAP {
665            assert!(
666                reference.contains(paradigm.name),
667                "paradigm {:?} is missing from the generated reference",
668                paradigm.name
669            );
670            for &category in paradigm.categories {
671                assert!(
672                    reference.contains(category_label(category)),
673                    "category {:?} for paradigm {:?} is missing from the reference",
674                    category_label(category),
675                    paradigm.name
676                );
677            }
678        }
679    }
680
681    /// The reference is deterministic (pure function of the catalogs + map).
682    #[test]
683    fn reference_is_deterministic() {
684        assert_eq!(type_reference_markdown(), type_reference_markdown());
685    }
686
687    /// The `docs/llms.txt` block wraps exactly the reference between markers.
688    #[test]
689    fn llms_section_wraps_reference() {
690        let section = type_llms_section();
691        assert!(section.starts_with(LLMS_BEGIN_MARKER));
692        assert!(section.ends_with(LLMS_END_MARKER));
693        assert!(section.contains(&type_reference_markdown()));
694    }
695
696    /// Type names resolve through the engine's own SQL parser — canonical
697    /// spelling and common aliases, case-insensitively.
698    #[test]
699    fn resolve_type_name_accepts_canonical_and_aliases() {
700        assert_eq!(resolve_type_name("INTEGER"), Some(DataType::Integer));
701        assert_eq!(resolve_type_name("int"), Some(DataType::Integer));
702        assert_eq!(resolve_type_name("string"), Some(DataType::Text));
703        assert_eq!(resolve_type_name("TEXT"), Some(DataType::Text));
704        assert_eq!(resolve_type_name("not-a-type"), None);
705    }
706
707    /// Bare JSON literals are typed structurally, the way the wire sees them.
708    #[test]
709    fn infer_literal_type_maps_json_shapes() {
710        assert_eq!(infer_literal_type(&JsonValue::Null), DataType::Nullable);
711        assert_eq!(
712            infer_literal_type(&JsonValue::Bool(true)),
713            DataType::Boolean
714        );
715        assert_eq!(
716            infer_literal_type(&JsonValue::Number(42.0)),
717            DataType::Integer
718        );
719        assert_eq!(infer_literal_type(&JsonValue::Number(3.5)), DataType::Float);
720        assert_eq!(
721            infer_literal_type(&JsonValue::String("hi".to_string())),
722            DataType::Text
723        );
724        assert_eq!(
725            infer_literal_type(&JsonValue::Array(vec![])),
726            DataType::Array
727        );
728        assert_eq!(
729            infer_literal_type(&JsonValue::Object(Map::new())),
730            DataType::Json
731        );
732    }
733
734    /// `casts_from` reports exactly the catalog rows whose `src` is the type,
735    /// and the catalog says INTEGER widens to FLOAT implicitly and losslessly.
736    #[test]
737    fn casts_from_reads_catalog_rows() {
738        let casts = casts_from(DataType::Integer);
739        let widen = casts
740            .iter()
741            .find(|(target, _, _)| *target == DataType::Float)
742            .expect("INTEGER → FLOAT cast present in catalog");
743        assert_eq!(widen.1, CastContext::Implicit);
744        assert!(!widen.2, "INTEGER → FLOAT must be lossless");
745        // Every returned row genuinely has INTEGER as its source.
746        for (target, _, _) in &casts {
747            assert!(CAST_CATALOG
748                .iter()
749                .any(|c| c.src == DataType::Integer && c.target == *target));
750        }
751    }
752
753    /// `operators_for` matches a type as either operand, and skips the prefix
754    /// `Nullable` left-operand marker.
755    #[test]
756    fn operators_for_matches_either_operand() {
757        let ops = operators_for(DataType::Integer);
758        assert!(
759            ops.iter().any(|e| e.name == "+"),
760            "INTEGER should accept the + operator"
761        );
762        // The prefix-`-` overload (unary negation) carries a Nullable lhs marker;
763        // it is matched via its (right) operand, never via the marker. So every
764        // matched entry that *does* carry a Nullable lhs must be a prefix whose
765        // right operand is the looked-up type.
766        for entry in &ops {
767            if entry.lhs_type == DataType::Nullable {
768                assert_eq!(entry.kind, OperatorKind::Prefix);
769                assert_eq!(entry.rhs_type, DataType::Integer);
770            } else {
771                assert!(entry.lhs_type == DataType::Integer || entry.rhs_type == DataType::Integer);
772            }
773        }
774    }
775
776    /// The structured answer carries the canonical type, category, casts and
777    /// operators — all sourced from the catalogs.
778    #[test]
779    fn type_facts_json_reports_canonical_casts_and_operators() {
780        let facts = type_facts_json(DataType::Integer);
781        assert_eq!(
782            facts.get("canonical_type").and_then(JsonValue::as_str),
783            Some("INTEGER")
784        );
785        assert_eq!(
786            facts.get("category").and_then(JsonValue::as_str),
787            Some("Numeric")
788        );
789        let casts = facts.get("casts").and_then(JsonValue::as_array).unwrap();
790        assert!(
791            casts
792                .iter()
793                .any(|c| c.get("target").and_then(JsonValue::as_str) == Some("FLOAT")),
794            "INTEGER → FLOAT must appear in the casts"
795        );
796        let operators = facts
797            .get("operators")
798            .and_then(JsonValue::as_array)
799            .unwrap();
800        assert!(
801            operators
802                .iter()
803                .any(|o| o.get("symbol").and_then(JsonValue::as_str) == Some("+")),
804            "the + operator must appear in the operators"
805        );
806    }
807
808    /// The query entry point resolves by type name, infers from a value, and
809    /// rejects an unknown type name.
810    #[test]
811    fn type_of_json_resolves_name_and_value() {
812        // By type name (alias).
813        let by_name = type_of_json(Some("int"), None).expect("known type name");
814        assert_eq!(
815            by_name.get("canonical_type").and_then(JsonValue::as_str),
816            Some("INTEGER")
817        );
818        // By JSON value literal.
819        let by_value = type_of_json(None, Some(&JsonValue::Bool(false))).expect("value");
820        assert_eq!(
821            by_value.get("canonical_type").and_then(JsonValue::as_str),
822            Some("BOOLEAN")
823        );
824        // Unknown name → None so the caller can surface a parse error.
825        assert!(type_of_json(Some("frobnicate"), None).is_none());
826        // Nothing supplied → None.
827        assert!(type_of_json(None, None).is_none());
828    }
829}