Natural language triples — parse short plain-English Subject–Predicate–Object phrases into validated triples.
You describe your domain with a grammar (the valid S-P-O relations) and a
vocabulary (word stems that map to predicates). nl3 then turns phrases like
"user jack contacts user jill" into a structured [Triple], validating each
one against the grammar and flipping reversed phrasings back into shape.
A triple represents a relationship as Subject → Predicate → Object — the data
model behind RDF
and triplestores. For example,
"user jack contacts user jill" becomes:
subject: { ty: "user", value: "jack" }
predicate: { value: "message" }
object: { ty: "user", value: "jill" }
[dependencies]
nl3-rs = "1.0"The crate is imported as nl3:
use nl3::Nl3;-
serde(off by default) — deriveSerialize/DeserializeonTriple,Term, andPredicate:[dependencies] nl3-rs = { version = "1.0", features = ["serde"] }
The minimum supported Rust version is 1.95 (the crate uses edition 2024 and
let-chains). The MSRV is pinned via rust-version and verified in CI.
use nl3::Nl3;
let nl3 = Nl3::builder()
.grammar(["users message users"])
.vocabulary([
("msg", "message"), // user jack msgs user jill
("messag", "message"), // user jack messaged user jill
("contact", "message"), // user jack contacted user jill
])
.build();
let triple = nl3.parse("user jack contacts user jill").unwrap();
assert_eq!(triple.subject.ty.as_deref(), Some("user"));
assert_eq!(triple.subject.value.as_deref(), Some("jack"));
assert_eq!(triple.predicate.value.as_deref(), Some("message"));
assert_eq!(triple.object.ty.as_deref(), Some("user"));
assert_eq!(triple.object.value.as_deref(), Some("jill"));All of these phrasings produce the same triple:
user jack msg user jill
user jack msgs user jill
user jack messaged user jill
user jack contacted user jill
user jack contacts user jill
Each grammar entry is a "Subject Predicate Object" string. All three words are
singularized when the rules are built, so "users message users" and
"user message user" are equivalent. The grammar defines which triples are
valid: a parsed phrase that does not fit any rule (in either direction) is
rejected.
The vocabulary maps word stems to predicates in your grammar. nl3 stems each candidate word (classic Porter stemmer, after singularizing) and looks it up here. This is how synonyms and tenses collapse onto one predicate:
.vocabulary([
("send", "send"), // sends
("sent", "send"), // sent
("mail", "send"), // mailed
])Tip: vocabulary keys are stems, not whole words.
"messaged"stems tomessag, so the key is"messag", not"message".
If a phrase is grammatically valid only when subject and object are swapped, nl3 flips it for you, so reversed phrasings still yield the correctly-oriented triple.
If a phrase omits an entity type, nl3 infers it from the grammar via the matched
predicate. Given "users message users", the predicate message only ever
relates a user to a user, so a bare phrase resolves both ends:
let nl3 = Nl3::builder()
.grammar(["users message users"])
.vocabulary([("contact", "message")])
.build();
let t = nl3.parse("jack contacts jill").unwrap();
assert_eq!(t.subject.ty.as_deref(), Some("user")); // inferred
assert_eq!(t.subject.value.as_deref(), Some("jack"));
assert_eq!(t.object.ty.as_deref(), Some("user")); // inferred
assert_eq!(t.object.value.as_deref(), Some("jill"));A type is inferred only when the predicate determines it. When a predicate maps
to more than one candidate type (e.g. both "users message users" and
"admins message users"), the [Ambiguity] policy decides:
use nl3::{Ambiguity, Nl3};
let nl3 = Nl3::builder()
.grammar(["users message users", "admins message users"])
.vocabulary([("contact", "message")])
.ambiguity(Ambiguity::FirstMatch) // default: take the first matching rule
.build();
// "users message users" is declared first, so jack is a user.
assert_eq!(
nl3.parse("jack contacts jill").unwrap().subject.ty.as_deref(),
Some("user"),
);Ambiguity::FirstMatch(default) — use the first matching rule, in grammar declaration order.Ambiguity::Error— return [ParseError::AmbiguousType] (with the predicate and candidate types) instead of guessing.
Spelling the type out ("user jack contacts user jill") always bypasses
inference.
pub struct Triple {
pub subject: Term,
pub predicate: Predicate,
pub object: Term,
}
pub struct Term {
pub ty: Option<String>, // entity type, e.g. "user"
pub value: Option<String>, // entity value, e.g. "jack"
}
pub struct Predicate {
pub ty: Option<String>, // always None (not implemented upstream)
pub value: Option<String>, // the mapped predicate, e.g. "message"
}Fields are Option because a phrase may omit a value (e.g. "users who follow user 42" has no subject value). The field is named ty because type is a
reserved word in Rust.
[Nl3::parse] returns Result<Triple, ParseError>:
pub enum ParseError {
EmptyInput, // text was empty or whitespace
InvalidTriple(Box<Triple>), // didn't match the grammar in either direction
AmbiguousType { // an omitted type couldn't be inferred uniquely
predicate: String,
candidates: Vec<String>,
},
}use nl3::{Nl3, ParseError};
let nl3 = Nl3::builder()
.grammar(["users follow users"])
.vocabulary([("follow", "follow")])
.build();
assert!(matches!(nl3.parse(""), Err(ParseError::EmptyInput)));
assert!(matches!(
nl3.parse("dog jim hates cat sue"),
Err(ParseError::InvalidTriple(_)),
));Under the hood nl3 applies a part-of-speech tagger to skip prepositions
(by, from, …) and WH-words (who, which, that). The default
[LexiconTagger] is a small closed-class lexicon that covers the common cases
with zero heavy dependencies.
To recognize more words — or to delegate to a real POS model such as
rust-bert — implement the [Tagger]
trait and pass it to the builder:
use nl3::{tagger::Tagger, Nl3};
struct MyTagger;
impl Tagger for MyTagger {
fn tag(&self, text: &str) -> Vec<(String, String)> {
text.split_whitespace()
.map(|t| (t.to_string(), "NN".to_string())) // ... your logic
.collect()
}
}
let nl3 = Nl3::builder()
.grammar(["users follow users"])
.vocabulary([("follow", "follow")])
.tagger(MyTagger)
.build();Runnable examples live in examples/:
| Example | What it shows |
|---|---|
basic |
One grammar rule; many phrasings → one triple |
messenger |
A fuller domain plus both error modes |
ambiguity |
Disallowing ambiguous inference with [Ambiguity::Error] |
custom_tagger |
Supplying your own [Tagger] |
adventure |
A playable text adventure — grammar as game affordances |
cargo run --example basic
cargo run --example messenger
cargo run --example ambiguity
cargo run --example custom_tagger
cargo run --example adventureA Makefile wraps the common Cargo tasks:
make # fmt + lint + test
make test # unit, integration, and doctests
make test-all # tests with all features (incl. serde)
make lint # clippy across all features, warnings denied
make bench # criterion benchmarks
make doc # build the API docs
make ci # fmt-check + lint + test + test-allCI (GitHub Actions, .github/workflows/ci.yml) runs
the same checks on stable and the pinned MSRV, across default and all features.
parse() is allocation-light and runs in single-digit microseconds. The
predicate stemmer — the heaviest per-token step — is memoized in a per-thread
cache, so repeated words (the common case) cost a hash lookup rather than a full
Porter-stemmer pass. Measured with the parse benchmark
(cargo bench), this roughly halves parse time on cache hits. The cache keeps
parse(&self) lock-free and is bounded to avoid unbounded growth.
Licensed under the MIT License by Hector Gray (@defstream).