Skip to content

Commit 68f22d1

Browse files
authored
Merge pull request #19 from nyxCore-Systems/release/v2.1.0
Release v2.1.0
2 parents 634efa2 + 9b1732b commit 68f22d1

19 files changed

Lines changed: 1975 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,47 @@ All notable changes to this project are documented here.
44

55
---
66

7+
## [2.1.0] — 2026-04-15
8+
9+
### Added
10+
11+
**v2.1 — Streaming context + forward-compat primitives**
12+
13+
**Streaming**
14+
15+
- **`StreamContext { file_uri, cursor_position, max_tokens, model? }`** — new streaming wire message. Daemon ranks symbols relevant to the cursor and emits one `SymbolInfo { symbol_info, relevance_score, token_cost }` frame at a time, terminating with exactly one `EndStream { reason, emitted, total_candidates, error? }` frame. Reasons: `budget_reached`, `exhausted`, `error`. Replaces the broken "fetch top-k, locally truncate to prompt budget" pattern with stream-until-full. Spec §9.2.
16+
- **Relevance ordering** (spec §2.3): direct symbol at cursor → callers (from blast-radius CPG walk) → callees / references → related types. Conservative token-cost estimate `ceil((len(signature) + len(documentation)) / 4) + 8` per symbol. Daemon does not buffer ahead of the socket; `BrokenPipe` from a closing client aborts the ranking walk cleanly. `StreamContext` is rejected from `Batch` / `BatchQuery`.
17+
- **`protocol_version` bumped from `1``2`** in `HandshakeResult`. Clients can detect streaming support via handshake.
18+
- **`lip stream-context <file_uri> <line:col> --max-tokens N [--model M]`** — new CLI subcommand prints frames as JSON for manual testing.
19+
20+
**New primitives**
21+
22+
- **`EmbedText { text, model? }`** — embed an arbitrary text string and return the raw vector. Closes the gap left by `EmbeddingBatch` (URI-only) and `QueryNearestByText` (embeds internally but discards the vector). Callers re-ranking with their own scoring (centroid arithmetic, federated nearest-neighbour, lexical-then-semantic re-rank) get the embedding directly instead of building a centroid out of nearest-neighbour seeds. Returns `EmbedTextResult { vector: Vec<f32>, embedding_model: String }`. Not permitted inside `BatchQuery` (requires async HTTP).
23+
- **`RegisterTier3Source { source: Tier3Source }`** + **`IndexStatusResult.tier3_sources`** — expose provenance for Tier 3 ingestion batches (SCIP imports). `Tier3Source { source_id, tool_name, tool_version, project_root, imported_at_ms }` records *what* producer generated the symbols and *when* the daemon accepted them. Re-registering the same `source_id` overwrites in place, refreshing `imported_at_ms`. The daemon deliberately does no staleness detection: stale Tier 3 symbols remain in the graph at their original confidence until the caller re-imports. Surfacing provenance lets clients decide when to warn a user that imported data has aged (e.g. `scip-rust imported 3 days ago`). `lip import --push-to-daemon` now sends this before streaming SCIP deltas, with `source_id = sha256(tool_name + ":" + project_root)`. `IndexStatusResult.tier3_sources` is `#[serde(default)]`; older daemons yield an empty vector. Ack'd with `DeltaAck`. Not permitted inside `BatchQuery` (mutation).
24+
- **`lip import --no-provenance`** — opt out of Tier 3 provenance registration for ephemeral or test imports that should not pollute a long-lived daemon's `tier3_sources` list. No effect on the default EventStream-JSON output path.
25+
26+
**Forward-compat & capability discovery**
27+
28+
- **`HandshakeResult.supported_messages: Vec<String>`** — handshake response now lists every `ClientMessage` `type` tag this daemon understands. Lets clients probe for an individual message (e.g. `stream_context`, `embed_text`) without writing "handshake then pray" code or comparing `protocol_version` integers. Field is `#[serde(default)]`; older daemons yield an empty vector, which clients should treat as "fall back to `protocol_version`."
29+
- **`ServerMessage::UnknownMessage { message_type, supported }`** — when a client sends a well-formed JSON envelope whose `type` tag is unknown, the daemon now replies with `UnknownMessage` (carrying the tag plus the same supported list as handshake) *and keeps the socket open*, instead of closing after a generic parse `Error`. Lets forward-compatible clients downgrade gracefully to a supported call instead of reconnecting.
30+
- **`ServerMessage::Error { message, code }`**`code: ErrorCode` is a stable, machine-readable category. Clients branch on this instead of string-matching `message`. `#[serde(default)]`; older daemons deserialize as `ErrorCode::Internal`.
31+
- **`ErrorCode`** enum — small, stable set: `unknown_message_type`, `unknown_model`, `embedding_not_configured`, `no_embedding`, `cursor_out_of_range`, `index_locked`, `invalid_request`, `internal` (default). Adding a code is non-breaking; renaming or removing one is breaking.
32+
- `embedding_not_configured` — daemon has no embedding service (`LIP_EMBEDDING_URL` unset).
33+
- `no_embedding` — URI has no cached embedding yet; call `EmbeddingBatch` first.
34+
- `unknown_model` — the embedding endpoint rejected the requested model. Emitted by the daemon when the HTTP backend returns 404 or a 4xx body matching `model_not_found` / `"unknown model"` / `"model … not found/invalid/unsupported"`. Transport, rate-limit, and auth errors stay on `internal` — retrying with the same model only makes sense after a real config change. Classification lives in `daemon/embedding.rs::classify_http_error`.
35+
- `invalid_request` — request was well-formed on the wire but used incorrectly (e.g. nested `Batch`, or `StreamContext` inside a `Batch`). Distinct from `internal` so clients can avoid retry loops on caller-side mistakes.
36+
37+
**Drift guard**
38+
39+
- **`ClientMessage::variant_tag`** + `supported_messages_covers_all_variants` test — exhaustive-match helper plus paired test that fails compilation when a new `ClientMessage` variant is added without being advertised in `supported_messages()`. Prevents capability-list drift from silently shrinking the handshake surface.
40+
41+
### Fixed
42+
43+
- **`QueryExpansion` handler contract pinned by a db-level test.** The post-embedding ranking is now encapsulated in `LipDatabase::query_expansion_terms(query_vec, actual_model, top_k)`, which the handler calls in one line. A regression that drops the model filter would cause `query_expansion_terms_rejects_cross_model_scoring` (db.rs) to fail, closing the earlier gap where the fix shipped without a paired assertion.
44+
- **`QueryExpansion` now honors the caller's model pin.** Previously the handler embedded the query with the requested model but then ranked candidates across *all* stored symbol embeddings regardless of which model produced them — cross-model cosine scores are not meaningful, so the returned "expansion terms" were effectively noise whenever the index held mixed-model vectors. Handler now captures the actual model returned by `embed_texts` and passes it through a new `model_filter: Option<&str>` parameter on `LipDatabase::nearest_symbol_by_vector`, restricting candidates to symbols embedded with the same model. `SimilarSymbols` (which resolves from a URI's own cached embedding) keeps the old unfiltered behavior by passing `None`.
45+
46+
---
47+
748
## [2.0.1] — 2026-04-13
849

950
### Changed

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "2.0.1"
10+
version = "2.1.0"
1111
edition = "2021"
1212
rust-version = "1.78"
1313
authors = ["Lisa Welsch <lisa@nyxcore.cloud>"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ Requires Rust 1.78+. No system `protoc` required.
379379

380380
## Status
381381

382-
v2.0 — `ExplainMatch` (chunk-level explanation: which lines in a result file drove the match), model provenance (`FileStatus` exposes the embedding model per file; `IndexStatus` warns when the index contains mixed-model vectors). v1.9: `filter` glob + `min_score` on all NN calls, `GetCentroid`, `QueryStaleEmbeddings`. v1.8: `FindBoundaries`, `SemanticDiff`, `QueryNearestInStore` (cross-repo federation), `QueryNoveltyScore`, `ExtractTerminology`, `PruneDeleted`. v1.7: 6 semantic retrieval primitives. v1.6: `ReindexFiles`, `Similarity`, `QueryExpansion`, `Cluster`, `ExportEmbeddings`. Wire format is JSON.
382+
v2.1 — `StreamContext` (token-budgeted RAG context streaming): callers stream symbols ranked by relevance to a cursor and stop reading when the prompt budget is full instead of fetching top-k and locally truncating; `protocol_version` bumped to `2`. v2.0 — `ExplainMatch` (chunk-level explanation: which lines in a result file drove the match), model provenance (`FileStatus` exposes the embedding model per file; `IndexStatus` warns when the index contains mixed-model vectors). v1.9: `filter` glob + `min_score` on all NN calls, `GetCentroid`, `QueryStaleEmbeddings`. v1.8: `FindBoundaries`, `SemanticDiff`, `QueryNearestInStore` (cross-repo federation), `QueryNoveltyScore`, `ExtractTerminology`, `PruneDeleted`. v1.7: 6 semantic retrieval primitives. v1.6: `ReindexFiles`, `Similarity`, `QueryExpansion`, `Cluster`, `ExportEmbeddings`. Wire format is JSON.
383383

384384
---
385385

bindings/rust/benches/framing.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criteri
77
use tokio::runtime::Runtime;
88

99
use lip_core::daemon::session::{read_message, write_message};
10-
use lip_core::query_graph::ServerMessage;
10+
use lip_core::query_graph::{ErrorCode, ServerMessage};
1111

1212
fn make_rt() -> Runtime {
1313
tokio::runtime::Builder::new_current_thread()
@@ -19,6 +19,7 @@ fn make_rt() -> Runtime {
1919
fn make_message(payload_bytes: usize) -> ServerMessage {
2020
ServerMessage::Error {
2121
message: "x".repeat(payload_bytes),
22+
code: ErrorCode::Internal,
2223
}
2324
}
2425

bindings/rust/src/daemon/embedding.rs

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,79 @@
1010
//! When `LIP_EMBEDDING_URL` is unset, [`EmbeddingClient::from_env`] returns `None`
1111
//! and all embedding requests return a sensible error to the caller.
1212
13-
use anyhow::Context;
1413
use serde::{Deserialize, Serialize};
1514

15+
/// Classified failure from the embedding HTTP endpoint.
16+
///
17+
/// The variants map directly to [`crate::query_graph::ErrorCode`]
18+
/// categories so the daemon can propagate a precise classification to
19+
/// clients instead of collapsing every endpoint failure into `Internal`.
20+
/// Callers that only need a display string should use the `Display` impl.
21+
#[derive(Debug)]
22+
pub enum EmbedError {
23+
/// The endpoint rejected the requested model name — either 404, or
24+
/// a 4xx whose body names the model. Maps to `ErrorCode::UnknownModel`.
25+
/// Retrying with the same model is pointless.
26+
UnknownModel(String),
27+
/// HTTP transport failure, timeout, or TLS error. Maps to
28+
/// `ErrorCode::Internal`. Retry is often safe.
29+
Transport(String),
30+
/// The endpoint returned a response we could not parse, or the
31+
/// vector count did not match the input count. Maps to
32+
/// `ErrorCode::Internal`. Indicates a backend misconfiguration.
33+
Protocol(String),
34+
/// Non-2xx status that does not clearly match any of the above.
35+
/// Maps to `ErrorCode::Internal`.
36+
Http(String),
37+
}
38+
39+
impl std::fmt::Display for EmbedError {
40+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41+
match self {
42+
EmbedError::UnknownModel(m)
43+
| EmbedError::Transport(m)
44+
| EmbedError::Protocol(m)
45+
| EmbedError::Http(m) => f.write_str(m),
46+
}
47+
}
48+
}
49+
50+
impl std::error::Error for EmbedError {}
51+
52+
/// Classify an embedding endpoint's non-2xx response into the narrowest
53+
/// applicable [`EmbedError`] variant.
54+
///
55+
/// Heuristic: 404 is always an unknown-model signal (OpenAI, Ollama, and
56+
/// most compatible backends 404 on an unrecognised model). Other 4xx are
57+
/// classified as `UnknownModel` only when the body mentions the model —
58+
/// OpenAI-compatible errors typically carry `"code":"model_not_found"`
59+
/// or a message containing `"model"` for this case. Everything else
60+
/// (5xx, 4xx without model keyword) falls through to `Http`.
61+
fn classify_http_error(status: reqwest::StatusCode, body: &str) -> EmbedError {
62+
let msg = format!("embedding endpoint returned {status}: {body}");
63+
if status == reqwest::StatusCode::NOT_FOUND {
64+
return EmbedError::UnknownModel(msg);
65+
}
66+
if status.is_client_error() {
67+
let lower = body.to_ascii_lowercase();
68+
if lower.contains("model_not_found") || lower.contains("unknown model") {
69+
return EmbedError::UnknownModel(msg);
70+
}
71+
// Conservative: generic 4xx with "model" mention, treat as model issue
72+
// only when combined with a "not found" / "invalid" / "unsupported" hint,
73+
// to avoid misclassifying auth / rate-limit errors.
74+
let looks_model_shaped = lower.contains("model")
75+
&& (lower.contains("not found")
76+
|| lower.contains("invalid")
77+
|| lower.contains("unsupported")
78+
|| lower.contains("does not exist"));
79+
if looks_model_shaped {
80+
return EmbedError::UnknownModel(msg);
81+
}
82+
}
83+
EmbedError::Http(msg)
84+
}
85+
1686
/// Thin client around a single OpenAI-compatible embedding endpoint.
1787
pub struct EmbeddingClient {
1888
url: String,
@@ -73,12 +143,14 @@ impl EmbeddingClient {
73143
///
74144
/// # Errors
75145
///
76-
/// Propagates HTTP, serialisation, and API errors.
146+
/// Returns an [`EmbedError`] classified so the daemon can map directly
147+
/// to a [`crate::query_graph::ErrorCode`] without inspecting the
148+
/// message string.
77149
pub async fn embed_texts(
78150
&self,
79151
texts: &[String],
80152
model_override: Option<&str>,
81-
) -> anyhow::Result<(Vec<Vec<f32>>, String)> {
153+
) -> Result<(Vec<Vec<f32>>, String), EmbedError> {
82154
if texts.is_empty() {
83155
return Ok((vec![], self.default_model.clone()));
84156
}
@@ -93,31 +165,89 @@ impl EmbeddingClient {
93165
.json(&body)
94166
.send()
95167
.await
96-
.context("embedding HTTP request failed")?;
168+
.map_err(|e| EmbedError::Transport(format!("embedding HTTP request failed: {e}")))?;
97169

98170
if !resp.status().is_success() {
99171
let status = resp.status();
100172
let text = resp.text().await.unwrap_or_default();
101-
anyhow::bail!("embedding endpoint returned {status}: {text}");
173+
return Err(classify_http_error(status, &text));
102174
}
103175

104176
let parsed: EmbedResponse = resp
105177
.json()
106178
.await
107-
.context("failed to parse embedding response")?;
179+
.map_err(|e| EmbedError::Protocol(format!("failed to parse embedding response: {e}")))?;
108180

109181
// Re-order by index field to match the input order.
110182
let mut data = parsed.data;
111183
data.sort_by_key(|d| d.index);
112184

113-
anyhow::ensure!(
114-
data.len() == texts.len(),
115-
"embedding endpoint returned {} vectors for {} inputs",
116-
data.len(),
117-
texts.len()
118-
);
185+
if data.len() != texts.len() {
186+
return Err(EmbedError::Protocol(format!(
187+
"embedding endpoint returned {} vectors for {} inputs",
188+
data.len(),
189+
texts.len()
190+
)));
191+
}
119192

120193
let vectors = data.into_iter().map(|d| d.embedding).collect();
121194
Ok((vectors, parsed.model))
122195
}
123196
}
197+
198+
#[cfg(test)]
199+
mod tests {
200+
use super::*;
201+
use reqwest::StatusCode;
202+
203+
#[test]
204+
fn classify_404_is_unknown_model() {
205+
let e = classify_http_error(StatusCode::NOT_FOUND, "model not found");
206+
assert!(matches!(e, EmbedError::UnknownModel(_)));
207+
}
208+
209+
#[test]
210+
fn classify_openai_model_not_found_code() {
211+
// OpenAI API shape.
212+
let body = r#"{"error":{"code":"model_not_found","message":"The model 'foo' does not exist"}}"#;
213+
let e = classify_http_error(StatusCode::BAD_REQUEST, body);
214+
assert!(matches!(e, EmbedError::UnknownModel(_)));
215+
}
216+
217+
#[test]
218+
fn classify_ollama_model_unknown() {
219+
let body = r#"{"error":"model 'nomic-embed-text' not found, try pulling it first"}"#;
220+
let e = classify_http_error(StatusCode::NOT_FOUND, body);
221+
assert!(matches!(e, EmbedError::UnknownModel(_)));
222+
}
223+
224+
#[test]
225+
fn classify_auth_error_stays_http() {
226+
// 401 unauthorized must not be misclassified as UnknownModel just
227+
// because a token payload might mention "model".
228+
let body = "Unauthorized";
229+
let e = classify_http_error(StatusCode::UNAUTHORIZED, body);
230+
assert!(matches!(e, EmbedError::Http(_)));
231+
}
232+
233+
#[test]
234+
fn classify_rate_limit_stays_http() {
235+
let e = classify_http_error(StatusCode::TOO_MANY_REQUESTS, "rate limit");
236+
assert!(matches!(e, EmbedError::Http(_)));
237+
}
238+
239+
#[test]
240+
fn classify_5xx_stays_http() {
241+
let e = classify_http_error(StatusCode::INTERNAL_SERVER_ERROR, "backend died");
242+
assert!(matches!(e, EmbedError::Http(_)));
243+
}
244+
245+
#[test]
246+
fn classify_4xx_mentioning_model_without_not_found_keyword_stays_http() {
247+
// "model temperature too high" would mention "model" but is not
248+
// an unknown-model signal. Conservative classifier keeps it Http.
249+
let body = "model temperature parameter rejected";
250+
let e = classify_http_error(StatusCode::BAD_REQUEST, body);
251+
assert!(matches!(e, EmbedError::Http(_)));
252+
}
253+
}

0 commit comments

Comments
 (0)