-
Notifications
You must be signed in to change notification settings - Fork 1
feat(rank): ToolRanker vocabulary, Bm25Ranker, and a Jev-backed tinytools-jev crate #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a512a77
e2544bc
574dc85
0b43a13
557b9c6
6695eb0
03649ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| [package] | ||
| publish = false | ||
| name = "tinytools-jev" | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| repository.workspace = true | ||
| description = "A tinytools ToolRanker backed by TypeSafe's Jev decision model: retrieve a shortlist lexically, let Jev decide." | ||
| documentation = "https://docs.rs/tinytools-jev" | ||
| readme = "README.md" | ||
|
|
||
| [dependencies] | ||
| async-trait = { workspace = true } | ||
| tinytools = { path = "../tinytools", version = "0.3.0" } | ||
|
senamakel marked this conversation as resolved.
|
||
| tracing = { workspace = true, optional = true } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new AGENTS.md reference: AGENTS.md:L150-L158 Useful? React with 👍 / 👎. |
||
|
|
||
| [dev-dependencies] | ||
| tokio = { workspace = true } | ||
|
|
||
| [features] | ||
| default = [] | ||
| tracing = ["dep:tracing"] | ||
|
|
||
| [lints] | ||
| workspace = true | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # tinytools-jev | ||
|
|
||
| A `tinytools::ToolRanker` backed by a host-provided Jev evaluator. The host | ||
| owns its HTTP client, credentials, retry policy, and deadline. | ||
|
|
||
| ## Retrieve, then decide | ||
|
|
||
| Jev answers a `Choice` question with a calibrated probability for every | ||
| option in ~150 ms, accepts at most 255 options, and loses accuracy as the | ||
| option list fills with entries unrelated to the request. So `JevRanker` | ||
| never shows it a whole catalogue: | ||
|
|
||
| 1. A retriever (`Bm25Ranker` unless the host supplies one) narrows the | ||
| catalogue to `retrieval_k` candidates (20 by default). Skipped when the | ||
| catalogue already fits. When the retriever finds nothing and the catalogue | ||
| fits one Choice, Jev sees all of it — a lexical miss on a paraphrase is | ||
| exactly the case a decision model is for. | ||
| 2. One request: a `Choice` over the shortlist plus a `none` option, and a | ||
| `Noul` asking whether the request needs a tool at all. | ||
| 3. Hits are the options by probability, `none` removed, anything below | ||
| `min_probability` dropped. Each hit's `confidence` is its probability; | ||
| `rank_detailed` also returns the Choice confidence, the `needs_tool` | ||
| probability, the `none` probability, tokens, latency and attempts. | ||
|
|
||
| Every evaluator failure is a `RankError` the caller falls back from. | ||
|
|
||
| ## Limits that shape the design | ||
|
|
||
| | Limit | Value | Consequence | | ||
| |------------------------------|--------|-----------------------------------------------| | ||
| | Options per Choice | 255 | `retrieval_k` is clamped to it | | ||
| | Summary shown per option | 240 ch | clipped, with the family named after | | ||
| | Context per request | 64k | `RankContext` stays to a few recent turns | | ||
| | Pricing (jev-1.13) | $0.042 / M input, output free | a search is ~1–2k tokens | | ||
|
|
||
| ## Wording | ||
|
|
||
| Jev reads literally. The instructions name the user's `request` and ask which | ||
| tool accomplishes it "by what each tool does, not by shared words"; each | ||
| option is `name: first sentence (from family)`; the state carries the request | ||
| and at most the caller's few recent turns. | ||
|
|
||
| ## Building a ranker | ||
|
|
||
| Implement `JevEvaluator` in the host by translating `JevRequest` into the | ||
| client's wire request and translating its answer into `JevDecision`. Pass that | ||
| implementation to `JevRanker::new`. This keeps transport and runtime choices | ||
| at the host boundary where their policy belongs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| //! Dependency-free Jev-backed tool ranking. | ||
| //! | ||
| //! A host supplies [`JevEvaluator`], retaining ownership of transport, | ||
| //! authentication, retry, and deadline policy. | ||
|
Comment on lines
+1
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new crate root stops after a four-line description and provides no runnable example showing how to implement AGENTS.md reference: AGENTS.md:L197-L201 Useful? React with 👍 / 👎. |
||
|
|
||
| #[cfg(test)] | ||
| mod test; | ||
| mod types; | ||
| pub use types::{JevDecision, JevEvaluator, JevOption, JevRankerConfig, JevRanking, JevRequest}; | ||
|
|
||
| use std::{ | ||
| collections::{BTreeMap, BTreeSet}, | ||
| sync::Arc, | ||
| time::Duration, | ||
| }; | ||
| use tinytools::{RankCandidate, RankContext, RankError, RankHit, ToolRanker}; | ||
| const NONE_OPTION: &str = "none"; | ||
| const MAX_SUMMARY_CHARS: usize = 240; | ||
|
|
||
| /// Ranks tools using a host-provided evaluator. | ||
| #[derive(Debug, Clone)] | ||
| pub struct JevRanker { | ||
| evaluator: Arc<dyn JevEvaluator>, | ||
| config: JevRankerConfig, | ||
| } | ||
| impl JevRanker { | ||
| /// Stable ranker kind. | ||
| pub const KIND: &'static str = "jev"; | ||
| /// Creates a ranker. | ||
| #[must_use] | ||
| pub fn new(evaluator: Arc<dyn JevEvaluator>, config: JevRankerConfig) -> Self { | ||
| Self { evaluator, config } | ||
| } | ||
| /// Returns the active configuration. | ||
| #[must_use] | ||
| pub fn config(&self) -> &JevRankerConfig { | ||
| &self.config | ||
| } | ||
| /// Ranks and returns all decision metadata. | ||
| /// | ||
| /// # Errors | ||
| /// Returns invalid-input errors and forwards evaluator/retriever failures. | ||
| pub async fn rank_detailed( | ||
| &self, | ||
| intent: &str, | ||
| context: &RankContext, | ||
| candidates: &[RankCandidate], | ||
| limit: usize, | ||
| ) -> Result<JevRanking, RankError> { | ||
| let intent = intent.trim(); | ||
| if intent.is_empty() { | ||
| return Err(RankError::invalid_input("intent is empty")); | ||
| } | ||
| if candidates.is_empty() || limit == 0 { | ||
| return Ok(JevRanking::empty()); | ||
| } | ||
| let shortlist = self.shortlist(intent, context, candidates).await?; | ||
| if shortlist.is_empty() { | ||
| return Ok(JevRanking::empty()); | ||
| } | ||
| let request = self.build_request(intent, context, &shortlist)?; | ||
| let started = std::time::Instant::now(); | ||
| let decision = self.evaluator.evaluate(&request).await?; | ||
| let mut ranking = decode(&decision, &shortlist, self.config.min_probability); | ||
| ranking.hits.truncate(limit); | ||
| ranking.latency = started.elapsed(); | ||
| Ok(ranking) | ||
| } | ||
| async fn shortlist<'a>( | ||
| &self, | ||
| intent: &str, | ||
| context: &RankContext, | ||
| candidates: &'a [RankCandidate], | ||
| ) -> Result<Vec<&'a RankCandidate>, RankError> { | ||
| validate_candidates(candidates)?; | ||
| let k = self.config.retrieval_k.min(JevRankerConfig::MAX_CANDIDATES); | ||
| if candidates.len() <= k { | ||
| return Ok(candidates.iter().collect()); | ||
| } | ||
| let hits = self | ||
| .config | ||
| .retriever | ||
| .rank(intent, context, candidates, k) | ||
| .await?; | ||
| let by_key: BTreeMap<&str, &RankCandidate> = | ||
| candidates.iter().map(|c| (c.key.as_str(), c)).collect(); | ||
| let shortlist: Vec<_> = hits | ||
| .iter() | ||
| .filter_map(|h| by_key.get(h.key.as_str()).copied()) | ||
| .take(JevRankerConfig::MAX_CANDIDATES) | ||
| .collect(); | ||
| if shortlist.is_empty() && candidates.len() <= JevRankerConfig::MAX_CANDIDATES { | ||
| return Ok(candidates.iter().collect()); | ||
| } | ||
| Ok(shortlist) | ||
| } | ||
| fn build_request( | ||
| &self, | ||
| intent: &str, | ||
| context: &RankContext, | ||
| shortlist: &[&RankCandidate], | ||
| ) -> Result<JevRequest, RankError> { | ||
| if shortlist.len() > JevRankerConfig::MAX_CANDIDATES { | ||
| return Err(RankError::invalid_input("too many shortlisted candidates")); | ||
| } | ||
| let mut options: Vec<_> = shortlist | ||
| .iter() | ||
| .map(|c| JevOption { | ||
| key: c.key.clone(), | ||
| description: option_text(c), | ||
| }) | ||
| .collect(); | ||
| options.push(JevOption { | ||
| key: NONE_OPTION.into(), | ||
| description: "No listed tool accomplishes the request.".into(), | ||
| }); | ||
| Ok(JevRequest { | ||
| intent: intent.into(), | ||
| recent_turns: context.recent_turns.clone(), | ||
| options, | ||
| model: self.config.model.clone(), | ||
| }) | ||
| } | ||
| } | ||
| #[async_trait::async_trait] | ||
| impl ToolRanker for JevRanker { | ||
| fn kind(&self) -> &'static str { | ||
| Self::KIND | ||
| } | ||
| async fn rank( | ||
| &self, | ||
| intent: &str, | ||
| context: &RankContext, | ||
| candidates: &[RankCandidate], | ||
| limit: usize, | ||
| ) -> Result<Vec<RankHit>, RankError> { | ||
| self.rank_detailed(intent, context, candidates, limit) | ||
| .await | ||
| .map(|r| r.hits) | ||
| } | ||
| } | ||
| impl JevRanking { | ||
| fn empty() -> Self { | ||
| Self { | ||
| hits: vec![], | ||
| choice_confidence: 0.0, | ||
| needs_tool: None, | ||
| none_probability: 0.0, | ||
| shortlisted: 0, | ||
| input_tokens: None, | ||
| latency: Duration::ZERO, | ||
| attempts: 0, | ||
| } | ||
| } | ||
| } | ||
| fn validate_candidates(candidates: &[RankCandidate]) -> Result<(), RankError> { | ||
| let mut keys = BTreeSet::new(); | ||
| for c in candidates { | ||
| if c.key == NONE_OPTION { | ||
| return Err(RankError::invalid_input("candidate key `none` is reserved")); | ||
| } | ||
| if !keys.insert(c.key.as_str()) { | ||
| return Err(RankError::invalid_input("duplicate candidate key")); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn option_text(candidate: &RankCandidate) -> String { | ||
| let mut summary: String = candidate.summary.chars().take(MAX_SUMMARY_CHARS).collect(); | ||
| if candidate.summary.chars().count() > MAX_SUMMARY_CHARS { | ||
| summary.push('…'); | ||
| } | ||
| candidate.family.as_ref().map_or(summary.clone(), |family| { | ||
| format!("{summary} (from {family})") | ||
| }) | ||
| } | ||
| fn decode(decision: &JevDecision, shortlist: &[&RankCandidate], floor: f64) -> JevRanking { | ||
| let none = decision | ||
| .probabilities | ||
| .get(NONE_OPTION) | ||
| .copied() | ||
| .unwrap_or(0.0); | ||
| let best = shortlist | ||
| .iter() | ||
| .filter_map(|c| decision.probabilities.get(&c.key).copied()) | ||
| .fold(0.0_f64, f64::max); | ||
| let abstained = none >= best || decision.needs_tool.is_some_and(|p| p < 0.5); | ||
| let mut hits: Vec<_> = if abstained { | ||
| vec![] | ||
| } else { | ||
| shortlist | ||
| .iter() | ||
| .filter_map(|c| { | ||
| let p = *decision.probabilities.get(&c.key)?; | ||
| (p >= floor).then(|| RankHit { | ||
| key: c.key.clone(), | ||
| score: p, | ||
| confidence: Some(p), | ||
|
Comment on lines
+194
to
+198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a host evaluator returns an out-of-range value such as Useful? React with 👍 / 👎. |
||
| }) | ||
| }) | ||
| .collect() | ||
| }; | ||
| hits.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key))); | ||
| JevRanking { | ||
| hits, | ||
| choice_confidence: decision.choice_confidence, | ||
| needs_tool: decision.needs_tool, | ||
| none_probability: none, | ||
| shortlisted: shortlist.len(), | ||
| input_tokens: decision.input_tokens, | ||
| latency: Duration::ZERO, | ||
| attempts: decision.attempts, | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This description no longer matches the added crate: its manifest has no
tinyjevclientor HTTP dependency, andJevRanker::newinstead requires a host-providedJevEvaluator. Update this section to describe that boundary so consumers are not incorrectly told that selecting this crate brings in a specific client and transport.AGENTS.md reference: AGENTS.md:L204-L205
Useful? React with 👍 / 👎.