nl3
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.
What is a triple?
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" }
Installation
[]
= "0.1"
The crate is imported as nl3:
use Nl3;
Cargo features
-
serde(off by default) — deriveSerialize/DeserializeonTriple,Term, andPredicate:[] = { = "0.1", = ["serde"] }
MSRV
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.
Quick start
use Nl3;
let nl3 = builder
.grammar
.vocabulary
.build;
let triple = nl3.parse.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
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
Concepts
Grammar
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.
Vocabulary
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
Tip: vocabulary keys are stems, not whole words.
"messaged"stems tomessag, so the key is"messag", not"message".
Flipping
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.
Type inference
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 = builder
.grammar
.vocabulary
.build;
let t = nl3.parse.unwrap;
assert_eq!; // inferred
assert_eq!;
assert_eq!; // inferred
assert_eq!;
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 ;
let nl3 = builder
.grammar
.vocabulary
.ambiguity // default: take the first matching rule
.build;
// "users message users" is declared first, so jack is a user.
assert_eq!;
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.
The Triple type
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.
Errors
[Nl3::parse] returns Result<Triple, ParseError>:
use ;
let nl3 = builder
.grammar
.vocabulary
.build;
assert!;
assert!;
Custom taggers
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 ;
;
let nl3 = builder
.grammar
.vocabulary
.tagger
.build;
Examples
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] |
cargo run --example basic
cargo run --example messenger
cargo run --example ambiguity
cargo run --example custom_tagger
Development
A 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-all
CI (GitHub Actions, .github/workflows/ci.yml) runs
the same checks on stable and the pinned MSRV, across default and all features.
Performance
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.
License
Licensed under the MIT License by Hector Gray (@defstream).