Skip to main content

steeldb/text/
ot.rs

1//! Entropy-regularised optimal transport (Sinkhorn–Knopp) + k-means codebook — the ontology-sensing
2//! numeric core, ported from `src/ingest/spo/ot.ts` (itself the port of python spo_sinkhorn.py).
3//! Vectors are assumed L2-normalised, so cosine == dot. This is what turns a cloud of span embeddings
4//! into a MECE facet codebook — no hand-authored schema.
5
6pub type Vec32 = Vec<f32>;
7
8fn dot(a: &[f32], b: &[f32]) -> f32 {
9    let mut s = 0.0;
10    for i in 0..a.len() {
11        s += a[i] * b[i];
12    }
13    s
14}
15
16fn l2(v: &[f32]) -> Vec32 {
17    let mut n = 0.0f32;
18    for &x in v {
19        n += x * x;
20    }
21    n = n.sqrt() + 1e-9;
22    v.iter().map(|x| x / n).collect()
23}
24
25/// k-means++ over cosine (vectors L2-normalised → argmax dot = nearest). Returns k prototypes.
26pub fn kmeans(x: &[Vec32], k: usize, iters: usize, seed: u32) -> Vec<Vec32> {
27    let k = k.max(2).min((x.len() / 4).max(2));
28    let dim = x[0].len();
29    let mut s = seed;
30    let mut rnd = || {
31        s = s.wrapping_mul(1664525).wrapping_add(1013904223);
32        s as f64 / 4294967296.0
33    };
34    let mut c: Vec<Vec32> = vec![x[(rnd() * x.len() as f64) as usize].clone()];
35    while c.len() < k {
36        let d2: Vec<f32> = x
37            .iter()
38            .map(|xi| {
39                let best = c.iter().fold(-1.0f32, |b, cj| b.max(dot(xi, cj)));
40                (1.0 - best).max(1e-6)
41            })
42            .collect();
43        let tot: f32 = d2.iter().sum();
44        let mut r = rnd() as f32 * tot;
45        let mut idx = 0;
46        while idx < d2.len() && {
47            r -= d2[idx];
48            r > 0.0
49        } {
50            idx += 1;
51        }
52        c.push(x[idx.min(x.len() - 1)].clone());
53    }
54    for _ in 0..iters {
55        let mut sum = vec![vec![0.0f32; dim]; k];
56        let mut cnt = vec![0usize; k];
57        for xi in x {
58            let mut a = 0;
59            let mut bestv = f32::NEG_INFINITY;
60            for j in 0..k {
61                let d = dot(xi, &c[j]);
62                if d > bestv {
63                    bestv = d;
64                    a = j;
65                }
66            }
67            cnt[a] += 1;
68            for i in 0..dim {
69                sum[a][i] += xi[i];
70            }
71        }
72        let mut moved = false;
73        for j in 0..k {
74            if cnt[j] == 0 {
75                continue;
76            }
77            let nc = l2(&sum[j]);
78            if 1.0 - dot(&nc, &c[j]) > 1e-5 {
79                moved = true;
80            }
81            c[j] = nc;
82        }
83        if !moved {
84            break;
85        }
86    }
87    c
88}
89
90/// Sinkhorn OT. `cost[n][k]` (cosine distance); uniform source + target marginals. Returns (plan, cost).
91///
92/// This is unmodified Sinkhorn–Knopp: Gibbs kernel `exp(-C/eps)` with uniform marginals. Use
93/// [`sinkhorn_weighted`] when the rows are derived from reified hyperedges, where degree is not uniform.
94pub fn sinkhorn(m: &[Vec<f32>], eps: f32, iters: usize) -> (Vec<Vec<f32>>, f32) {
95    let n = m.len();
96    let k = m[0].len();
97    let kmat: Vec<Vec<f32>> = m.iter().map(|row| row.iter().map(|v| (-v / eps).exp()).collect()).collect();
98    let (a, b) = (1.0 / n as f32, 1.0 / k as f32);
99    let mut u = vec![1.0f32; n];
100    let mut v = vec![1.0f32; k];
101    for _ in 0..iters {
102        for i in 0..n {
103            let mut s = 0.0;
104            for j in 0..k {
105                s += kmat[i][j] * v[j];
106            }
107            u[i] = a / (s + 1e-12);
108        }
109        for j in 0..k {
110            let mut s = 0.0;
111            for i in 0..n {
112                s += kmat[i][j] * u[i];
113            }
114            v[j] = b / (s + 1e-12);
115        }
116    }
117    let mut cost = 0.0;
118    let pi: Vec<Vec<f32>> = (0..n)
119        .map(|i| {
120            (0..k)
121                .map(|j| {
122                    let p = u[i] * kmat[i][j] * v[j];
123                    cost += p * m[i][j];
124                    p
125                })
126                .collect()
127        })
128        .collect();
129    (pi, cost)
130}
131
132/// Sinkhorn OT with a **non-uniform source marginal**.
133///
134/// NOTE ON PROVENANCE: this is an extension, not the reference behaviour. Both the reference Python
135/// (`spo_sinkhorn.py`) and [`sinkhorn`] above use uniform source AND target marginals — the solver is
136/// unmodified textbook Sinkhorn–Knopp in both. The reference's adaptation to reified hyperedges happens
137/// UPSTREAM of the solver: spans are clustered separately per kind, and non-semantic kinds (quantities,
138/// boilerplate) are routed out to numeric rules rather than entering the codebook at all.
139///
140/// Standard Sinkhorn gives every row the same mass `1/n`. That is correct when rows are interchangeable
141/// samples, and wrong when rows are terms drawn from reified hyperedges, because hyperedge degree is heavily
142/// skewed: a hub entity participates in a large fraction of all statements while a rare one appears twice.
143/// Under a uniform source marginal the solver must move the same mass for both, so a supernode's term is
144/// forced to spread across topics it has no real affinity for, and the topic it truly belongs to is diluted.
145///
146/// Weighting each row by its hyperedge support fixes the asymmetry: mass now reflects how much evidence the
147/// corpus actually has for that term. The target marginal stays uniform, because that is what prevents any
148/// one topic from collapsing and absorbing the others.
149///
150/// `support[i]` is the number of reified statements term `i` participates in. Weights are normalised to sum
151/// to 1; a zero or missing support falls back to uniform so the function is total.
152pub fn sinkhorn_weighted(m: &[Vec<f32>], eps: f32, iters: usize, support: &[f32]) -> (Vec<Vec<f32>>, f32) {
153    let n = m.len();
154    let k = m[0].len();
155    let kmat: Vec<Vec<f32>> = m.iter().map(|row| row.iter().map(|v| (-v / eps).exp()).collect()).collect();
156
157    // source marginal from hyperedge support; dampened with a square root so a hub does not take over the
158    // plan entirely — the same reason term weighting uses sqrt(tf) rather than tf
159    let mut a: Vec<f32> = (0..n)
160        .map(|i| support.get(i).copied().unwrap_or(0.0).max(0.0).sqrt())
161        .collect();
162    let total: f32 = a.iter().sum();
163    if total <= 0.0 {
164        a = vec![1.0 / n as f32; n];
165    } else {
166        for x in a.iter_mut() {
167            *x /= total;
168        }
169    }
170    let b = 1.0 / k as f32;
171
172    let mut u = vec![1.0f32; n];
173    let mut v = vec![1.0f32; k];
174    for _ in 0..iters {
175        for i in 0..n {
176            let mut s = 0.0;
177            for j in 0..k {
178                s += kmat[i][j] * v[j];
179            }
180            u[i] = a[i] / (s + 1e-12);
181        }
182        for j in 0..k {
183            let mut s = 0.0;
184            for i in 0..n {
185                s += kmat[i][j] * u[i];
186            }
187            v[j] = b / (s + 1e-12);
188        }
189    }
190    let mut cost = 0.0;
191    let pi: Vec<Vec<f32>> = (0..n)
192        .map(|i| {
193            (0..k)
194                .map(|j| {
195                    let p = u[i] * kmat[i][j] * v[j];
196                    cost += p * m[i][j];
197                    p
198                })
199                .collect()
200        })
201        .collect();
202    (pi, cost)
203}
204
205/// Codebook = k-means prototypes; assign each vector via the Sinkhorn plan (argmax over targets).
206pub fn codebook(x: &[Vec32], k: usize, eps: f32) -> (Vec<Vec32>, Vec<usize>, f32) {
207    if x.len() < 2 {
208        return (x.to_vec(), vec![0; x.len()], 0.0);
209    }
210    let protos = kmeans(x, k, 60, 1);
211    let m: Vec<Vec<f32>> = x.iter().map(|xi| protos.iter().map(|c| 1.0 - dot(xi, c)).collect()).collect();
212    let (pi, cost) = sinkhorn(&m, eps, 200);
213    let assign = pi
214        .iter()
215        .map(|row| {
216            let mut a = 0;
217            let mut bv = f32::NEG_INFINITY;
218            for (j, &p) in row.iter().enumerate() {
219                if p > bv {
220                    bv = p;
221                    a = j;
222                }
223            }
224            a
225        })
226        .collect();
227    (protos, assign, cost)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn weighted_sinkhorn_respects_hyperedge_support() {
236        let cost = vec![vec![0.05f32, 0.95], vec![0.10, 0.90], vec![0.95, 0.05]];
237        let support = vec![100.0f32, 50.0, 2.0];
238        let (uni, _) = sinkhorn(&cost, 0.1, 80);
239        let (wei, _) = sinkhorn_weighted(&cost, 0.1, 80, &support);
240        let row_mass = |p: &Vec<Vec<f32>>, i: usize| p[i].iter().sum::<f32>();
241        assert!((row_mass(&uni, 0) - row_mass(&uni, 2)).abs() < 1e-4);
242        assert!(row_mass(&wei, 0) > row_mass(&wei, 2) * 3.0);
243        assert!(wei[0][0] > wei[0][1]);
244        assert!(wei[2][1] > wei[2][0]);
245    }
246
247    #[test]
248    fn weighted_sinkhorn_falls_back_to_uniform_without_support() {
249        let cost = vec![vec![0.1f32, 0.9], vec![0.9, 0.1]];
250        let (a, _) = sinkhorn(&cost, 0.1, 50);
251        let (b, _) = sinkhorn_weighted(&cost, 0.1, 50, &[0.0, 0.0]);
252        for i in 0..2 { for j in 0..2 { assert!((a[i][j]-b[i][j]).abs() < 1e-5); } }
253    }
254}