Skip to content

Commit 4558e61

Browse files
hyperpolymathclaude
andcommitted
feat(corpus): vector-octad embeddings — Step 5 of N-dim plan
Adds `corpus/embed.rs` with the `Embedder` trait and a default `HashEmbedder` (offline feature-hashing-trick at 32 dimensions to match the GNN local-embedding `FEATURE_DIM`). Wires embeddings into `octad.rs::save_octads_jsonl` so the Vector modality populates with each entry's vector, model name, and dimensions. ## Why hashing-trick first `gnn::GnnClient::embed` is the proper backend but the Julia ML server at port 8090 is currently scaffold-only (per CLAUDE.md: `models/neural/` doesn't exist; cosine fallback returns zero vectors). Hashing-trick is offline, deterministic, free, and at 32 dimensions gives meaningful cosine similarity over shared vocabulary. Drop-in replacement when the GNN server is primed: implement `Embedder` for a `GnnEmbedder` and swap one trait usage in the corpus emit path. ## CLI `echidna corpus near <query> [--index <path>] [--top K]` — embeds the query with the same hasher, computes cosine similarity against every entry, prints top-K by score. Verified end-to-end on echo-types: `corpus near "WellFounded" --top 5` returns a plausible neighbourhood (CNO-Echo entries score 0.85, declining ranks below). At 32 dims collisions are common; real GNN embeddings will sharpen this. ## What this unblocks * SA / MCTS priors: `learning::buchholz_rank::initial` could now seed the search from "the K entries nearest to the goal", instead of a hard-coded baseline. * Cross-prover similarity: same vocabulary in Coq, Lean, Idris 2, Agda all hash into the same 32 buckets, so the Vector octad joins them transparently. * The capstone DSL (Step 6 / task #11) gets `near_vector` as a query primitive. ## Tests 3 new embed tests + 44 existing corpus tests all pass. * Verifies dimension stability across entries * Verifies cosine well-definedness for shared vocabulary * Verifies self-cosine = 1.0 (top NN of any entry is itself) ## Known limitations * 32-dim feature hashing has plenty of bucket collisions on large corpora (echo-types' 640 entries × ~10 tokens/entry hits the birthday bound easily). For impact-analysis use cases that's fine; for fine-grained similarity, swap in the GNN backend. * `Embedder` trait isn't currently object-safe (`embed` takes `&CorpusEntry` directly); converting to `dyn Embedder` is a one-line change when needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3148d14 commit 4558e61

4 files changed

Lines changed: 311 additions & 2 deletions

File tree

src/rust/corpus/embed.rs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// SPDX-FileCopyrightText: 2026 ECHIDNA Project Team
2+
// SPDX-License-Identifier: PMPL-1.0-or-later
3+
4+
//! Vector-octad embeddings for the corpus — Step 5 of the N-dim
5+
//! VeriSim plan.
6+
//!
7+
//! Two backends:
8+
//!
9+
//! 1. **Hashing-trick** (default, offline): tokenise statement +
10+
//! proof on whitespace + Agda-syntax separators, hash each token
11+
//! into one of 32 buckets, accumulate L2-normalised counts. Free,
12+
//! deterministic, no network. Good enough for cosine-similarity
13+
//! nearest-neighbour queries over shared vocabulary, which is
14+
//! exactly what the SA / MCTS prior layer needs.
15+
//!
16+
//! 2. **GNN client** (future, online): for when the Julia server at
17+
//! `/gnn/embed` is primed against real proof corpora (Wave-3 ML
18+
//! work tracked in `docs/handover/TODO.md`). The trait `Embedder`
19+
//! abstracts the choice; replacing the default `HashEmbedder`
20+
//! with a `GnnEmbedder` is one-line.
21+
//!
22+
//! The corpus's `compute_embeddings` returns `Vec<Vec<f32>>` parallel
23+
//! to `entries`; `octad.rs::save_octads_jsonl` populates each Vector
24+
//! modality from this. Cosine-similarity helper lives here too.
25+
26+
#![allow(dead_code)]
27+
28+
use serde::{Deserialize, Serialize};
29+
30+
use super::{Corpus, CorpusEntry};
31+
32+
/// Embedding dimension used by the default hashing-trick embedder.
33+
/// Chosen to match the 32-dim GNN local-term embeddings already
34+
/// shipped in `src/rust/gnn/embeddings.rs` (`FEATURE_DIM = 32`), so
35+
/// stored octads can be later re-embedded from the GNN client without
36+
/// changing dimensions.
37+
pub const HASH_EMBED_DIM: usize = 32;
38+
39+
#[derive(Debug, Clone, Serialize, Deserialize)]
40+
pub struct EmbedderInfo {
41+
pub model: String,
42+
pub dimensions: usize,
43+
}
44+
45+
pub trait Embedder {
46+
fn info(&self) -> EmbedderInfo;
47+
fn embed(&self, entry: &CorpusEntry) -> Vec<f32>;
48+
}
49+
50+
/// Default offline embedder: hashing-trick over the entry's
51+
/// statement + proof tokens, with L2 normalisation.
52+
#[derive(Debug, Clone, Default)]
53+
pub struct HashEmbedder;
54+
55+
impl Embedder for HashEmbedder {
56+
fn info(&self) -> EmbedderInfo {
57+
EmbedderInfo {
58+
model: "echidna-corpus-hash-v1".to_string(),
59+
dimensions: HASH_EMBED_DIM,
60+
}
61+
}
62+
63+
fn embed(&self, entry: &CorpusEntry) -> Vec<f32> {
64+
let mut buckets = vec![0.0_f32; HASH_EMBED_DIM];
65+
let mut text = entry.statement.clone();
66+
if let Some(p) = &entry.proof {
67+
text.push(' ');
68+
text.push_str(p);
69+
}
70+
for tok in tokenise(&text) {
71+
let idx = bucket_for(tok);
72+
buckets[idx] += 1.0;
73+
}
74+
l2_normalise(&mut buckets);
75+
buckets
76+
}
77+
}
78+
79+
fn tokenise(s: &str) -> impl Iterator<Item = &str> {
80+
s.split(|c: char| {
81+
c.is_whitespace()
82+
|| matches!(
83+
c,
84+
'(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '=' | ':' | '"' | '|'
85+
)
86+
})
87+
.filter(|t| !t.is_empty())
88+
}
89+
90+
/// FxHash-style fast hash; we only need stable bucketing, not crypto.
91+
fn bucket_for(tok: &str) -> usize {
92+
let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset
93+
for b in tok.as_bytes() {
94+
h ^= *b as u64;
95+
h = h.wrapping_mul(0x100000001b3);
96+
}
97+
(h as usize) % HASH_EMBED_DIM
98+
}
99+
100+
fn l2_normalise(v: &mut [f32]) {
101+
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
102+
if norm > 1e-9 {
103+
for x in v.iter_mut() {
104+
*x /= norm;
105+
}
106+
}
107+
}
108+
109+
/// Cosine similarity between two vectors of the same dimension.
110+
/// Returns 0.0 if either is zero.
111+
pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
112+
if a.len() != b.len() {
113+
return 0.0;
114+
}
115+
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
116+
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
117+
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
118+
if na < 1e-9 || nb < 1e-9 {
119+
0.0
120+
} else {
121+
dot / (na * nb)
122+
}
123+
}
124+
125+
impl Corpus {
126+
/// Compute embeddings for every entry using the default
127+
/// `HashEmbedder`. Result is parallel to `self.entries`.
128+
pub fn compute_embeddings(&self) -> Vec<Vec<f32>> {
129+
let embedder = HashEmbedder;
130+
self.entries.iter().map(|e| embedder.embed(e)).collect()
131+
}
132+
133+
/// Embed a free-form text query using the same default
134+
/// embedder. Used for `corpus query --near <text>`.
135+
pub fn embed_query(&self, query: &str) -> Vec<f32> {
136+
let synthetic = CorpusEntry {
137+
name: String::new(),
138+
qualified: String::new(),
139+
module_idx: 0,
140+
kind: super::DeclKind::Function,
141+
statement: query.to_string(),
142+
proof: None,
143+
line: 0,
144+
dependencies: vec![],
145+
axiom_usage: super::AxiomUsage::default(),
146+
};
147+
HashEmbedder.embed(&synthetic)
148+
}
149+
150+
/// Top-k nearest entries to `query_vec` by cosine similarity.
151+
/// Each tuple is `(entry-index, similarity)`, sorted descending.
152+
pub fn nearest_neighbours(&self, query_vec: &[f32], k: usize) -> Vec<(usize, f32)> {
153+
let embeddings = self.compute_embeddings();
154+
let mut scored: Vec<(usize, f32)> = embeddings
155+
.iter()
156+
.enumerate()
157+
.map(|(i, v)| (i, cosine(query_vec, v)))
158+
.collect();
159+
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
160+
scored.truncate(k);
161+
scored
162+
}
163+
}
164+
165+
#[cfg(test)]
166+
mod tests {
167+
use super::*;
168+
use crate::corpus::{AxiomUsage, DeclKind, ModuleEntry};
169+
use std::path::PathBuf;
170+
171+
fn sample_corpus() -> Corpus {
172+
let mut c = Corpus {
173+
adapter: "agda".into(),
174+
..Default::default()
175+
};
176+
c.modules.push(ModuleEntry {
177+
name: "M".into(),
178+
path: PathBuf::from("M.agda"),
179+
options: vec![],
180+
imports: vec![],
181+
entries: vec![0, 1, 2],
182+
});
183+
c.entries.push(CorpusEntry {
184+
name: "wf-<".into(),
185+
qualified: "M.wf-<".into(),
186+
module_idx: 0,
187+
kind: DeclKind::Function,
188+
statement: "WellFounded _<_".into(),
189+
proof: Some("acc lambda".into()),
190+
line: 1,
191+
dependencies: vec![],
192+
axiom_usage: AxiomUsage::default(),
193+
});
194+
c.entries.push(CorpusEntry {
195+
name: "Acc".into(),
196+
qualified: "M.Acc".into(),
197+
module_idx: 0,
198+
kind: DeclKind::Data,
199+
statement: "Acc R x".into(),
200+
proof: None,
201+
line: 5,
202+
dependencies: vec![],
203+
axiom_usage: AxiomUsage::default(),
204+
});
205+
c.entries.push(CorpusEntry {
206+
name: "Nat".into(),
207+
qualified: "M.Nat".into(),
208+
module_idx: 0,
209+
kind: DeclKind::Data,
210+
statement: "data Nat where zero suc".into(),
211+
proof: None,
212+
line: 10,
213+
dependencies: vec![],
214+
axiom_usage: AxiomUsage::default(),
215+
});
216+
c.reindex();
217+
c
218+
}
219+
220+
#[test]
221+
fn hash_embedder_dimensions() {
222+
let c = sample_corpus();
223+
let embeds = c.compute_embeddings();
224+
assert_eq!(embeds.len(), 3);
225+
for v in &embeds {
226+
assert_eq!(v.len(), HASH_EMBED_DIM);
227+
}
228+
}
229+
230+
#[test]
231+
fn cosine_similar_for_shared_vocab() {
232+
let c = sample_corpus();
233+
let embeds = c.compute_embeddings();
234+
// wf-< (mentions WellFounded + _<_) and Acc (mentions R + x)
235+
// should both be non-zero similar to a query about
236+
// well-foundedness.
237+
let q = c.embed_query("WellFounded _<_");
238+
let s_wf = cosine(&q, &embeds[0]);
239+
let s_acc = cosine(&q, &embeds[1]);
240+
let s_nat = cosine(&q, &embeds[2]);
241+
assert!(s_wf > 0.0, "wf-< should be similar to WellFounded query");
242+
assert!(s_wf >= s_nat, "wf-< should beat Nat for WF query");
243+
// Acc may or may not beat Nat depending on hash collisions;
244+
// the assertion is just that similarity is well-defined.
245+
assert!(s_acc.is_finite());
246+
}
247+
248+
#[test]
249+
fn nearest_neighbours_top1_is_self() {
250+
let c = sample_corpus();
251+
let embeds = c.compute_embeddings();
252+
let nn = c.nearest_neighbours(&embeds[0], 1);
253+
assert_eq!(nn.len(), 1);
254+
assert_eq!(nn[0].0, 0);
255+
assert!((nn[0].1 - 1.0).abs() < 1e-5, "self-cosine should be 1.0");
256+
}
257+
}

src/rust/corpus/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
pub mod agda;
3737
pub mod coq;
38+
pub mod embed; // Vector-octad embeddings (Step 5)
3839
pub mod idris2;
3940
pub mod lean;
4041
pub mod metrics; // Per-entry metrics tensor (Step 4)

src/rust/corpus/octad.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,13 @@ impl Corpus {
407407
} else {
408408
timestamp
409409
};
410-
// Compute the rich metrics tensor once for the whole corpus
411-
// (Step 4); attach to each octad's Tensor modality.
410+
// Compute the rich metrics tensor + embeddings once for the
411+
// whole corpus (Steps 4, 5). Tensor → tensor.metrics; vectors
412+
// → vector.goal_embedding.
412413
let metrics_per_entry = self.compute_metrics();
414+
let embedder = super::embed::HashEmbedder;
415+
let info = <super::embed::HashEmbedder as super::embed::Embedder>::info(&embedder);
416+
let embeddings = self.compute_embeddings();
413417
let mut out = String::new();
414418
for (i, entry) in self.entries.iter().enumerate() {
415419
let module = &self.modules[entry.module_idx];
@@ -421,6 +425,9 @@ impl Corpus {
421425
let mut octad =
422426
DeclarationOctad::from_entry(entry, module, &self.adapter, &rev, ts);
423427
octad.tensor.metrics = metrics_per_entry[i].as_metric_map();
428+
octad.vector.goal_embedding = embeddings[i].clone();
429+
octad.vector.model = info.model.clone();
430+
octad.vector.dimensions = info.dimensions;
424431
let line = serde_json::to_string(&octad).context("serialise octad")?;
425432
out.push_str(&line);
426433
out.push('\n');

src/rust/main.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,17 @@ enum CorpusOp {
273273
synonyms_dir: Option<PathBuf>,
274274
},
275275

276+
/// Find the K nearest-neighbour entries to a free-form text
277+
/// query by cosine similarity over the Vector octad embeddings.
278+
/// Useful for "find lemmas semantically near `WellFounded _<_`".
279+
Near {
280+
query: String,
281+
#[arg(short, long)]
282+
index: Option<PathBuf>,
283+
#[arg(short, long, default_value_t = 10)]
284+
top: usize,
285+
},
286+
276287
/// Convert a JSON corpus index into 8-modality octad JSONL
277288
/// (one DeclarationOctad per line). Streamable; compatible with
278289
/// VeriSim's `/api/v1/octads` endpoint when posted line-by-line.
@@ -1307,6 +1318,39 @@ fn corpus_command(op: CorpusOp, formatter: &OutputFormatter) -> Result<()> {
13071318
}
13081319
}
13091320

1321+
CorpusOp::Near { query, index, top } => {
1322+
let path = match index {
1323+
Some(p) => p,
1324+
None => {
1325+
let basename = std::env::current_dir()?
1326+
.file_name()
1327+
.map(|s| s.to_string_lossy().into_owned())
1328+
.unwrap_or_else(|| "project".to_string());
1329+
default_index(&basename)
1330+
}
1331+
};
1332+
let corpus = Corpus::load_json(&path)
1333+
.with_context(|| format!("load corpus index {}", path.display()))?;
1334+
let q_vec = corpus.embed_query(&query);
1335+
let nn = corpus.nearest_neighbours(&q_vec, top);
1336+
if nn.is_empty() {
1337+
formatter.info(&format!(
1338+
"no entries near '{}' (corpus has {} entries)",
1339+
query,
1340+
corpus.entries.len()
1341+
))?;
1342+
return Ok(());
1343+
}
1344+
for (idx, sim) in &nn {
1345+
let entry = &corpus.entries[*idx];
1346+
println!(
1347+
"{:.3} {} [{:?}]",
1348+
sim, entry.qualified, entry.kind
1349+
);
1350+
println!(" : {}", entry.statement);
1351+
}
1352+
}
1353+
13101354
CorpusOp::ExportOctads { index, out } => {
13111355
let corpus = Corpus::load_json(&index)
13121356
.with_context(|| format!("load corpus index {}", index.display()))?;

0 commit comments

Comments
 (0)