sqlite_graphrag/entity_type.rs
1//! Entity type vocabulary: open by default, with a canonical set as guidance.
2//!
3//! Until v1.2.8 this module owned a closed `EntityType` enum of thirteen kinds
4//! and folded every other label onto the nearest one, terminating at `concept`.
5//! The fold was lossy in the strict sense: the string the caller wrote was
6//! consumed inside `Deserialize` and never existed as a value again, so no
7//! layer above could report it, store it, or decide policy about it. On the
8//! database of this workspace that put 69% of all entities in a single bucket,
9//! which makes filtering by `concept` indistinguishable from not filtering.
10//!
11//! v1.2.8 opens the vocabulary instead of widening it, mirroring what
12//! `V010__open_relation_vocabulary.sql` did for relations back in v1.0.49: the
13//! SQL `CHECK` is gone (V017), the label travels as a plain `String`, and
14//! [`crate::entity_type::CANONICAL_ENTITY_TYPES`] is advice rather than a gate.
15//! The path is fully qualified on purpose: a `//!` header is concatenated with
16//! the `///` written above `pub mod` in `lib.rs` and RESOLVES in that outer
17//! scope, so a bare name here fails to resolve and `cargo doc` exits 101.
18//! Because nothing is
19//! folded any more, the caller's label survives by construction — it needs no
20//! second column to be preserved, only the absence of something destroying it.
21//!
22//! What remains enforced is *shape*, never membership. See
23//! [`crate::entity_type::normalize_entity_type`].
24
25use crate::constants::MAX_ENTITY_TYPE_LEN;
26use crate::errors::AppError;
27use crate::i18n::validation;
28
29/// The thirteen kinds that were canonical while the vocabulary was closed.
30///
31/// They stay as the recommended vocabulary — surfaced in help text, offered as
32/// completion candidates, and enforced under `--strict-entity-types` — but they
33/// no longer bound what can be stored. Deliberately mirrors
34/// [`crate::parsers::CANONICAL_RELATIONS`], which has played exactly this role
35/// for the relation vocabulary since v1.0.49.
36///
37/// Kept sorted so emitted diagnostics are stable.
38pub const CANONICAL_ENTITY_TYPES: &[&str] = &[
39 "concept",
40 "dashboard",
41 "date",
42 "decision",
43 "file",
44 "incident",
45 "issue_tracker",
46 "location",
47 "memory",
48 "organization",
49 "person",
50 "project",
51 "tool",
52];
53
54/// Kind assigned when a caller supplies no type at all.
55///
56/// This is the one place `concept` still wins by default. It is a default, not
57/// a destination: a label that simply differs from the canonical set is now
58/// stored as written, and only an *absent* label lands here.
59pub const DEFAULT_ENTITY_TYPE: &str = "concept";
60
61/// Reports whether `s` is one of [`CANONICAL_ENTITY_TYPES`].
62///
63/// Compares the already-normalised form, so callers should pass the output of
64/// [`normalize_entity_type`]. Mirrors `parsers::is_canonical_relation`.
65#[must_use]
66pub fn is_canonical_entity_type(s: &str) -> bool {
67 CANONICAL_ENTITY_TYPES.contains(&s)
68}
69
70/// Normalises an entity type label's *shape*, never its meaning.
71///
72/// Applies exactly three transformations — trim, lowercase, and hyphen to
73/// underscore — so `"Issue-Tracker"` and `"issue_tracker"` remain the same
74/// row rather than two, and so does `"Crate"` versus `"crate"`. It performs no
75/// mapping whatsoever: an unrecognised label comes back as itself, which is
76/// the whole point of the change.
77///
78/// Rejection is limited to labels that could not be a word in any vocabulary:
79/// empty, digits only, containing a line break, or longer than
80/// [`MAX_ENTITY_TYPE_LEN`]. Membership is never a reason to reject here;
81/// that decision belongs to `--strict-entity-types`, one layer up, where the
82/// caller has asked for it.
83///
84/// # Errors
85/// Returns [`AppError::Validation`] when the label is blank, digits only,
86/// contains a line break, or exceeds [`MAX_ENTITY_TYPE_LEN`] characters.
87pub fn normalize_entity_type(s: &str) -> Result<String, AppError> {
88 let normalized = s.trim().to_lowercase().replace('-', "_");
89
90 if normalized.is_empty() {
91 return Err(AppError::Validation(validation::entity_type_blank()));
92 }
93 if normalized.contains('\n') || normalized.contains('\r') {
94 return Err(AppError::Validation(validation::entity_type_has_newline(
95 &normalized,
96 )));
97 }
98 if normalized.chars().all(|c| c.is_ascii_digit()) {
99 return Err(AppError::Validation(validation::entity_type_digits_only(
100 &normalized,
101 )));
102 }
103 if normalized.chars().count() > MAX_ENTITY_TYPE_LEN {
104 return Err(AppError::Validation(validation::entity_type_too_long(
105 &normalized,
106 MAX_ENTITY_TYPE_LEN,
107 )));
108 }
109
110 Ok(normalized)
111}
112
113/// Normalises `s`, falling back to [`DEFAULT_ENTITY_TYPE`] when it is unusable.
114///
115/// For the read paths that materialise a label already stored in SQLite, where
116/// refusing is not an option because the row exists either way. Write paths
117/// must call [`normalize_entity_type`] and surface the error instead.
118#[must_use]
119pub fn normalize_entity_type_or_default(s: &str) -> String {
120 normalize_entity_type(s).unwrap_or_else(|_| DEFAULT_ENTITY_TYPE.to_string())
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn canonical_set_has_thirteen_sorted_members() {
129 assert_eq!(CANONICAL_ENTITY_TYPES.len(), 13);
130 let mut sorted = CANONICAL_ENTITY_TYPES.to_vec();
131 sorted.sort_unstable();
132 assert_eq!(
133 sorted.as_slice(),
134 CANONICAL_ENTITY_TYPES,
135 "kept sorted so diagnostics are stable"
136 );
137 }
138
139 #[test]
140 fn canonical_labels_are_recognised() {
141 for kind in CANONICAL_ENTITY_TYPES {
142 assert!(is_canonical_entity_type(kind), "{kind} must be canonical");
143 }
144 }
145
146 #[test]
147 fn shape_normalisation_is_case_and_hyphen_insensitive() {
148 assert_eq!(
149 normalize_entity_type(" Issue-Tracker ").unwrap(),
150 "issue_tracker"
151 );
152 assert_eq!(normalize_entity_type("PERSON").unwrap(), "person");
153 }
154
155 /// The regression this whole change exists to prevent: a label outside the
156 /// canonical set must come back as itself, not as `concept`.
157 #[test]
158 fn non_canonical_labels_survive_verbatim() {
159 for label in ["crate", "gap", "flag", "migration", "schema", "framework"] {
160 let normalized = normalize_entity_type(label).unwrap();
161 assert_eq!(normalized, label, "{label} must not be folded");
162 assert!(
163 !is_canonical_entity_type(&normalized),
164 "{label} is not canonical, but is still storable"
165 );
166 }
167 }
168
169 /// `framework` was on the deliberate fold list until v1.2.8. Pinned
170 /// separately because reintroducing that map would pass every other test.
171 #[test]
172 fn previously_folded_labels_are_no_longer_folded() {
173 for label in [
174 "framework",
175 "library",
176 "method",
177 "metric",
178 "platform",
179 "protocol",
180 ] {
181 assert_eq!(normalize_entity_type(label).unwrap(), label);
182 }
183 }
184
185 #[test]
186 fn blank_and_digit_only_labels_are_refused() {
187 assert!(normalize_entity_type("").is_err());
188 assert!(normalize_entity_type(" ").is_err());
189 assert!(normalize_entity_type("42").is_err());
190 }
191
192 #[test]
193 fn line_breaks_are_refused() {
194 assert!(normalize_entity_type("person\nrole").is_err());
195 assert!(normalize_entity_type("person\rrole").is_err());
196 }
197
198 #[test]
199 fn overlong_labels_are_refused_by_characters_not_bytes() {
200 let long = "a".repeat(MAX_ENTITY_TYPE_LEN + 1);
201 assert!(normalize_entity_type(&long).is_err());
202
203 let at_limit = "a".repeat(MAX_ENTITY_TYPE_LEN);
204 assert!(normalize_entity_type(&at_limit).is_ok());
205
206 // Multi-byte characters count once each, never by their UTF-8 width.
207 let accented = "á".repeat(MAX_ENTITY_TYPE_LEN);
208 assert!(normalize_entity_type(&accented).is_ok());
209 }
210
211 #[test]
212 fn default_is_used_only_when_normalisation_fails() {
213 assert_eq!(normalize_entity_type_or_default("crate"), "crate");
214 assert_eq!(normalize_entity_type_or_default(""), DEFAULT_ENTITY_TYPE);
215 }
216}