|
| 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 | +} |
0 commit comments