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::Number(n) => {
391            if n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
392                DataType::Integer
393            } else {
394                DataType::Float
395            }
396        }
397        JsonValue::String(_) => DataType::Text,
398        JsonValue::Array(_) => DataType::Array,
399        JsonValue::Object(_) => DataType::Json,
400    }
401}
402
403/// Human label for a [`CastContext`], matching the engine's coercion vocabulary.
404fn cast_context_label(context: CastContext) -> &'static str {
405    match context {
406        CastContext::Implicit => "implicit",
407        CastContext::Assignment => "assignment",
408        CastContext::Explicit => "explicit",
409    }
410}
411
412/// Human label for an [`OperatorKind`].
413fn operator_kind_label(kind: OperatorKind) -> &'static str {
414    match kind {
415        OperatorKind::Infix => "infix",
416        OperatorKind::Prefix => "prefix",
417        OperatorKind::Postfix => "postfix",
418    }
419}
420
421/// Every cast *out of* `ty` registered in [`CAST_CATALOG`], in catalog order.
422/// Each tuple is `(target, context, lossy)` read straight from the catalog row.
423pub fn casts_from(ty: DataType) -> Vec<(DataType, CastContext, bool)> {
424    CAST_CATALOG
425        .iter()
426        .filter(|cast| cast.src == ty)
427        .map(|cast| (cast.target, cast.context, cast.lossy))
428        .collect()
429}
430
431/// Every operator overload in [`OPERATOR_CATALOG`] that accepts `ty` as one of
432/// its operands, in catalog order. The `Nullable` left-operand marker on prefix
433/// operators is treated as "no left operand", so a prefix `-` matches only when
434/// `ty` is its (right) operand, never via the marker.
435pub fn operators_for(ty: DataType) -> Vec<&'static crate::operator_catalog::OperatorEntry> {
436    OPERATOR_CATALOG
437        .iter()
438        .filter(|entry| {
439            let lhs_matches = entry.kind != OperatorKind::Prefix && entry.lhs_type == ty;
440            lhs_matches || entry.rhs_type == ty
441        })
442        .collect()
443}
444
445/// Build the structured `reddb_type_of` answer for a resolved [`DataType`]: its
446/// canonical name + category, the casts available out of it, and the operator
447/// overloads that accept it — all read live from the catalogs.
448pub fn type_facts_json(ty: DataType) -> JsonValue {
449    let casts: Vec<JsonValue> = casts_from(ty)
450        .into_iter()
451        .map(|(target, context, lossy)| {
452            let mut obj = Map::new();
453            obj.insert("target".to_string(), JsonValue::String(target.to_string()));
454            obj.insert(
455                "context".to_string(),
456                JsonValue::String(cast_context_label(context).to_string()),
457            );
458            obj.insert("lossy".to_string(), JsonValue::Bool(lossy));
459            JsonValue::Object(obj)
460        })
461        .collect();
462
463    let operators: Vec<JsonValue> = operators_for(ty)
464        .into_iter()
465        .map(|entry| {
466            let mut obj = Map::new();
467            obj.insert(
468                "symbol".to_string(),
469                JsonValue::String(entry.name.to_string()),
470            );
471            obj.insert(
472                "kind".to_string(),
473                JsonValue::String(operator_kind_label(entry.kind).to_string()),
474            );
475            // Prefix operators carry a `Nullable` left-operand marker, not a real
476            // operand — surface that as JSON null rather than leaking the marker.
477            let lhs = if entry.kind == OperatorKind::Prefix {
478                JsonValue::Null
479            } else {
480                JsonValue::String(entry.lhs_type.to_string())
481            };
482            obj.insert("lhs".to_string(), lhs);
483            obj.insert(
484                "rhs".to_string(),
485                JsonValue::String(entry.rhs_type.to_string()),
486            );
487            obj.insert(
488                "returns".to_string(),
489                JsonValue::String(entry.return_type.to_string()),
490            );
491            JsonValue::Object(obj)
492        })
493        .collect();
494
495    let mut obj = Map::new();
496    obj.insert(
497        "canonical_type".to_string(),
498        JsonValue::String(ty.to_string()),
499    );
500    obj.insert(
501        "category".to_string(),
502        JsonValue::String(category_label(ty.category()).to_string()),
503    );
504    obj.insert(
505        "is_preferred".to_string(),
506        JsonValue::Bool(ty.is_preferred()),
507    );
508    obj.insert("casts".to_string(), JsonValue::Array(casts));
509    obj.insert("operators".to_string(), JsonValue::Array(operators));
510    JsonValue::Object(obj)
511}
512
513/// Answer a `reddb_type_of` query. Exactly one of `type_name` or `value` should
514/// carry the lookup subject: a type name (resolved with [`resolve_type_name`])
515/// or a JSON literal whose structural type is inferred with
516/// [`infer_literal_type`]. Returns the structured facts from [`type_facts_json`]
517/// wrapped with the resolved canonical type, or `None` when a supplied type name
518/// is unknown to the engine.
519pub fn type_of_json(type_name: Option<&str>, value: Option<&JsonValue>) -> Option<JsonValue> {
520    let ty = match (type_name, value) {
521        (Some(name), _) => resolve_type_name(name)?,
522        (None, Some(literal)) => infer_literal_type(literal),
523        (None, None) => return None,
524    };
525    Some(type_facts_json(ty))
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    /// Independently re-derive the concrete value types from the three
533    /// authorities and assert the published catalog equals that set — the
534    /// anti-drift guarantee: the reference stays in sync with the
535    /// function/operator/cast catalogs.
536    #[test]
537    fn catalog_matches_authorities() {
538        let mut expected: Vec<DataType> = Vec::new();
539        let mut record = |ty: DataType| {
540            if matches!(ty, DataType::Unknown | DataType::Nullable) {
541                return;
542            }
543            if !expected.contains(&ty) {
544                expected.push(ty);
545            }
546        };
547        for entry in FUNCTION_CATALOG {
548            for &arg in entry.arg_types {
549                record(arg);
550            }
551            record(entry.return_type);
552        }
553        for entry in OPERATOR_CATALOG {
554            record(entry.lhs_type);
555            record(entry.rhs_type);
556            record(entry.return_type);
557        }
558        for entry in CAST_CATALOG {
559            record(entry.src);
560            record(entry.target);
561        }
562        expected.sort_by_key(|ty| *ty as u8);
563
564        assert_eq!(
565            catalogued_value_types(),
566            expected,
567            "the published value-type catalog drifted from the function/operator/cast \
568authorities in reddb-io-types"
569        );
570    }
571
572    /// The published catalog excludes the catalog sentinels and is non-empty.
573    #[test]
574    fn catalog_excludes_sentinels() {
575        let types = catalogued_value_types();
576        assert!(!types.is_empty(), "value-type catalog must not be empty");
577        assert!(
578            !types.contains(&DataType::Unknown),
579            "Unknown is a catalog placeholder, not a value type"
580        );
581        assert!(
582            !types.contains(&DataType::Nullable),
583            "Nullable is a prefix-operator marker, not a value type"
584        );
585    }
586
587    /// The catalog is sorted by discriminant, so the generated reference is
588    /// stable and reviewable.
589    #[test]
590    fn catalog_is_sorted_and_unique() {
591        let types = catalogued_value_types();
592        let mut sorted = types.clone();
593        sorted.sort_by_key(|ty| *ty as u8);
594        assert_eq!(types, sorted, "catalogued_value_types must be sorted");
595        let mut deduped = types.clone();
596        deduped.dedup();
597        assert_eq!(
598            deduped.len(),
599            types.len(),
600            "catalog must not contain duplicates"
601        );
602    }
603
604    /// authorities ⊆ reference: every catalogued value type appears, by its
605    /// display name, in the generated reference.
606    #[test]
607    fn reference_lists_every_value_type() {
608        let reference = type_reference_markdown();
609        for ty in catalogued_value_types() {
610            assert!(
611                reference.contains(&format!("`{ty}`")),
612                "value type {ty} from the catalogs is missing from the generated type \
613reference"
614            );
615        }
616    }
617
618    /// Every operator symbol from the catalog appears in the reference.
619    #[test]
620    fn reference_lists_every_operator() {
621        let reference = type_reference_markdown();
622        for symbol in operator_symbols() {
623            assert!(
624                reference.contains(&format!("`{symbol}`")),
625                "operator {symbol:?} is missing from the generated type reference"
626            );
627        }
628    }
629
630    /// The multi-model map enumerates all seven paradigms RedDB stores, and each
631    /// is layered over the type catalog by at least one category family.
632    #[test]
633    fn multi_model_map_covers_every_paradigm() {
634        let names: Vec<&str> = MULTI_MODEL_MAP.iter().map(|m| m.name).collect();
635        for expected in [
636            "Documents",
637            "Key-value",
638            "Queues",
639            "Graph nodes & edges",
640            "Vault secrets",
641            "Config",
642            "RQL-tabular",
643        ] {
644            assert!(
645                names.contains(&expected),
646                "multi-model map is missing the {expected:?} paradigm"
647            );
648        }
649        for paradigm in MULTI_MODEL_MAP {
650            assert!(
651                !paradigm.categories.is_empty(),
652                "paradigm {:?} must link to at least one type category",
653                paradigm.name
654            );
655        }
656    }
657
658    /// Every paradigm and its category families render into the reference.
659    #[test]
660    fn reference_includes_multi_model_map() {
661        let reference = type_reference_markdown();
662        for paradigm in MULTI_MODEL_MAP {
663            assert!(
664                reference.contains(paradigm.name),
665                "paradigm {:?} is missing from the generated reference",
666                paradigm.name
667            );
668            for &category in paradigm.categories {
669                assert!(
670                    reference.contains(category_label(category)),
671                    "category {:?} for paradigm {:?} is missing from the reference",
672                    category_label(category),
673                    paradigm.name
674                );
675            }
676        }
677    }
678
679    /// The reference is deterministic (pure function of the catalogs + map).
680    #[test]
681    fn reference_is_deterministic() {
682        assert_eq!(type_reference_markdown(), type_reference_markdown());
683    }
684
685    /// The `docs/llms.txt` block wraps exactly the reference between markers.
686    #[test]
687    fn llms_section_wraps_reference() {
688        let section = type_llms_section();
689        assert!(section.starts_with(LLMS_BEGIN_MARKER));
690        assert!(section.ends_with(LLMS_END_MARKER));
691        assert!(section.contains(&type_reference_markdown()));
692    }
693
694    /// Type names resolve through the engine's own SQL parser — canonical
695    /// spelling and common aliases, case-insensitively.
696    #[test]
697    fn resolve_type_name_accepts_canonical_and_aliases() {
698        assert_eq!(resolve_type_name("INTEGER"), Some(DataType::Integer));
699        assert_eq!(resolve_type_name("int"), Some(DataType::Integer));
700        assert_eq!(resolve_type_name("string"), Some(DataType::Text));
701        assert_eq!(resolve_type_name("TEXT"), Some(DataType::Text));
702        assert_eq!(resolve_type_name("not-a-type"), None);
703    }
704
705    /// Bare JSON literals are typed structurally, the way the wire sees them.
706    #[test]
707    fn infer_literal_type_maps_json_shapes() {
708        assert_eq!(infer_literal_type(&JsonValue::Null), DataType::Nullable);
709        assert_eq!(
710            infer_literal_type(&JsonValue::Bool(true)),
711            DataType::Boolean
712        );
713        assert_eq!(
714            infer_literal_type(&JsonValue::Number(42.0)),
715            DataType::Integer
716        );
717        assert_eq!(infer_literal_type(&JsonValue::Number(3.5)), DataType::Float);
718        assert_eq!(
719            infer_literal_type(&JsonValue::String("hi".to_string())),
720            DataType::Text
721        );
722        assert_eq!(
723            infer_literal_type(&JsonValue::Array(vec![])),
724            DataType::Array
725        );
726        assert_eq!(
727            infer_literal_type(&JsonValue::Object(Map::new())),
728            DataType::Json
729        );
730    }
731
732    /// `casts_from` reports exactly the catalog rows whose `src` is the type,
733    /// and the catalog says INTEGER widens to FLOAT implicitly and losslessly.
734    #[test]
735    fn casts_from_reads_catalog_rows() {
736        let casts = casts_from(DataType::Integer);
737        let widen = casts
738            .iter()
739            .find(|(target, _, _)| *target == DataType::Float)
740            .expect("INTEGER → FLOAT cast present in catalog");
741        assert_eq!(widen.1, CastContext::Implicit);
742        assert!(!widen.2, "INTEGER → FLOAT must be lossless");
743        // Every returned row genuinely has INTEGER as its source.
744        for (target, _, _) in &casts {
745            assert!(CAST_CATALOG
746                .iter()
747                .any(|c| c.src == DataType::Integer && c.target == *target));
748        }
749    }
750
751    /// `operators_for` matches a type as either operand, and skips the prefix
752    /// `Nullable` left-operand marker.
753    #[test]
754    fn operators_for_matches_either_operand() {
755        let ops = operators_for(DataType::Integer);
756        assert!(
757            ops.iter().any(|e| e.name == "+"),
758            "INTEGER should accept the + operator"
759        );
760        // The prefix-`-` overload (unary negation) carries a Nullable lhs marker;
761        // it is matched via its (right) operand, never via the marker. So every
762        // matched entry that *does* carry a Nullable lhs must be a prefix whose
763        // right operand is the looked-up type.
764        for entry in &ops {
765            if entry.lhs_type == DataType::Nullable {
766                assert_eq!(entry.kind, OperatorKind::Prefix);
767                assert_eq!(entry.rhs_type, DataType::Integer);
768            } else {
769                assert!(entry.lhs_type == DataType::Integer || entry.rhs_type == DataType::Integer);
770            }
771        }
772    }
773
774    /// The structured answer carries the canonical type, category, casts and
775    /// operators — all sourced from the catalogs.
776    #[test]
777    fn type_facts_json_reports_canonical_casts_and_operators() {
778        let facts = type_facts_json(DataType::Integer);
779        assert_eq!(
780            facts.get("canonical_type").and_then(JsonValue::as_str),
781            Some("INTEGER")
782        );
783        assert_eq!(
784            facts.get("category").and_then(JsonValue::as_str),
785            Some("Numeric")
786        );
787        let casts = facts.get("casts").and_then(JsonValue::as_array).unwrap();
788        assert!(
789            casts
790                .iter()
791                .any(|c| c.get("target").and_then(JsonValue::as_str) == Some("FLOAT")),
792            "INTEGER → FLOAT must appear in the casts"
793        );
794        let operators = facts
795            .get("operators")
796            .and_then(JsonValue::as_array)
797            .unwrap();
798        assert!(
799            operators
800                .iter()
801                .any(|o| o.get("symbol").and_then(JsonValue::as_str) == Some("+")),
802            "the + operator must appear in the operators"
803        );
804    }
805
806    /// The query entry point resolves by type name, infers from a value, and
807    /// rejects an unknown type name.
808    #[test]
809    fn type_of_json_resolves_name_and_value() {
810        // By type name (alias).
811        let by_name = type_of_json(Some("int"), None).expect("known type name");
812        assert_eq!(
813            by_name.get("canonical_type").and_then(JsonValue::as_str),
814            Some("INTEGER")
815        );
816        // By JSON value literal.
817        let by_value = type_of_json(None, Some(&JsonValue::Bool(false))).expect("value");
818        assert_eq!(
819            by_value.get("canonical_type").and_then(JsonValue::as_str),
820            Some("BOOLEAN")
821        );
822        // Unknown name → None so the caller can surface a parse error.
823        assert!(type_of_json(Some("frobnicate"), None).is_none());
824        // Nothing supplied → None.
825        assert!(type_of_json(None, None).is_none());
826    }
827}