A small, library-friendly Rust crate for pairwise matching of course records modelled on schema.org/Course.
Status. Stable. Public API + 76 unit tests + a 16-test integration suite (
tests/public_api.rs, driving the re-exported surface end-to-end); Soundex phonetic bonus (+0.05on thenamecomponent, capped at0.95) ships as T-6. Service-side bridge — 14 contract tests pinning identifier routing + deterministic short-circuits — lives in the embeddingcourse-servicecrate attests/duplicate_detection.rs.
use course_matcher::{Course, IdentifierScheme, MatchConfig, MatchingEngine};
let engine = MatchingEngine::new(MatchConfig::default());
let mut a = Course::new("Introduction to Computer Science");
a.course_code = Some("CS101".into());
a.provider_id = Some("ror-021nxhr62".into());
a.keywords = vec!["programming".into(), "algorithms".into()];
let mut b = Course::new("Intro to Computer Science");
b.course_code = Some("CS 101".into());
b.provider_id = Some("ror-021nxhr62".into());
let result = engine.match_courses(&a, &b);
assert!(result.is_match); // 1.0 — same provider + same course code
assert!(result.breakdown.deterministic_match);When no deterministic rule fires, the score is a renormalised
weighted average and every contributing component is reported in the
breakdown — the crate's headline explainability feature. Here two
courses share a near-identical name and overlapping keywords but no
provider/identifier signal:
use course_matcher::{Course, MatchConfig, MatchingEngine};
let engine = MatchingEngine::new(MatchConfig::default());
let mut a = Course::new("Introduction to Computer Science");
a.keywords = vec!["programming".into(), "algorithms".into()];
let mut b = Course::new("Intro to Computer Science");
b.keywords = vec!["programming".into(), "data structures".into()];
let r = engine.match_courses(&a, &b);
assert!(!r.breakdown.deterministic_match); // no R-0/R-1/R-2 fired
// Components that BOTH records carry are scored; absent ones are None
// and excluded from the divisor (renormalisation).
assert!(r.breakdown.name_score.is_some()); // high Jaro-Winkler
assert_eq!(r.breakdown.keywords_score, Some(1.0 / 3.0)); // 1 shared of 3
assert!(r.breakdown.course_code_score.is_none());
assert!(r.breakdown.provider_score.is_none());
assert!(r.breakdown.educational_level_score.is_none());
assert!(r.breakdown.teaches_score.is_none());
// score = (name * 0.35 + keywords * 0.10) / (0.35 + 0.10)match_one_to_many keeps candidate order; rank sorts by descending
score (returning (index, result)); find_matches is rank plus a
threshold filter — pick the shape that fits the caller.
Two strategies:
- Deterministic — score 1.0 when both records share (a) a value
on a deterministic identifier scheme (DOI, Wikidata, LOM, OER,
URI, UUID), (b)
provider_id+course_code(normalised), or (c) asame_asURL. - Probabilistic — weighted average over the components both records actually carry. Absent fields don't penalise the score (renormalisation).
Default weights:
| Component | Weight |
|---|---|
| Name (Jaro-Winkler, with alternate-name fallbacks) | 0.35 |
| Provider-scoped course code | 0.15 |
Provider (provider_id exact / provider_name Jaro-Winkler) |
0.15 |
| Educational level (with adjacent-ladder partial credit) | 0.10 |
| Keywords (Jaccard on folded set) | 0.10 |
| Teaches / competencies (Jaccard) | 0.15 |
Match is_match threshold: 0.85. Confidence bands: ≥0.95 High,
≥0.70 Medium, otherwise Low.
Full derivation: spec.md +
AGENTS/matching-algorithm.md.
use course_matcher::{
Course, CourseIdentifier, IdentifierScheme,
EducationalLevel, LearningResourceType,
MatchingEngine, MatchConfig,
MatchResult, MatchBreakdown, Confidence,
};strsimfor Jaro-Winkler.unicode-normalizationfor NFKC normalisation.serde/serde_jsonfor round-trip.thiserrorfor the error enum.mimalloc— demo binary only, under amusltarget gate.
No axum, no tokio, no tantivy. Embedding services (e.g.
course-service) bring those
themselves and adapt their richer Course shape down to ours.
cargo test --lib # 76 unit tests (encoder + scoring + normalisation + bonus)
cargo test --test public_api # 16 integration tests (public re-exported surface)
cargo run # demo binary — worked examples through the public API
cargo doc --open # rustdoc
# Benches live in the embedding service crate so they exercise the
# adapter + facade path that production calls:
cd ../course-service-with-loco && cargo benchThe demo binary (src/main.rs) is illustrative only and
not part of the SemVer surface. It is the sole consumer of the
musl-gated mimalloc allocator; the library sets no global allocator.
Dual-licensed under MIT OR Apache-2.0 OR BSD-3-Clause OR GPL-2.0-only OR GPL-3.0-only.