Skip to main content

steeldb/
mece.rs

1//! **MECE gate** — the information-gain test that decides whether a facet earns its place
2//! (`design.py` §"MECE gate: Collectively-Exhaustive (coverage) × Mutually-Exclusive (orthogonality)").
3//!
4//! The reference implementation measures orthogonality as cosine between SPLADE *decoder weight*
5//! vectors. Over a roaring index the same semantics are available directly from the postings — a facet's
6//! incidence vector over situations *is* its activation — so:
7//!
8//! * **coverage** (Collectively Exhaustive): `|situations where the facet fires| / N`
9//! * **maxcos** (Mutually Exclusive): the largest cosine between this facet's incidence vector and any
10//!   other facet's, i.e. how redundant it is
11//! * **gain** = `coverage × (1 − maxcos)` — high only when a facet covers a lot *and* covers something
12//!   nothing else does. A candidate is kept iff `gain ≥ threshold` (an MDL-style split criterion).
13//!
14//! Computing this on the index rather than on model weights is both cheaper and more honest: it scores
15//! the facets as the corpus actually instantiates them, not as the heads were trained.
16
17use crate::bitmap::Postings;
18use crate::index::InfonIndex;
19use serde::Serialize;
20use std::collections::BTreeMap;
21
22#[derive(Debug, Clone, Serialize)]
23pub struct FacetScore {
24    pub facet: String,
25    /// distinct tokens under this facet
26    pub tokens: usize,
27    /// fraction of situations where the facet fires (Collectively Exhaustive)
28    pub coverage: f64,
29    /// largest cosine against another facet's incidence vector (Mutually Exclusive; lower is better)
30    pub maxcos: f64,
31    /// the facet most redundant with this one
32    pub nearest: String,
33    /// coverage × (1 − maxcos)
34    pub gain: f64,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct MeceReport {
39    pub situations: u32,
40    pub facets: Vec<FacetScore>,
41    /// mean coverage across facets — the set's Collectively-Exhaustive score
42    pub mean_coverage: f64,
43    /// mean maxcos — the set's (inverse) Mutually-Exclusive score
44    pub mean_maxcos: f64,
45    pub mean_gain: f64,
46}
47
48impl MeceReport {
49    /// Facets that fail the gain threshold — redundant or too sparse to earn a retrieval head.
50    pub fn failing(&self, threshold: f64) -> Vec<&FacetScore> {
51        self.facets.iter().filter(|f| f.gain < threshold).collect()
52    }
53}
54
55/// Union of every posting set under `facet` — the facet's incidence vector over situations (coverage).
56fn facet_incidence<B: Postings>(ix: &InfonIndex<B>, facet: &str) -> B {
57    let mut acc = B::empty();
58    for tok in facet_tokens(ix, facet) {
59        acc.or_inplace(&ix.post(&tok));
60    }
61    acc
62}
63
64/// The tokens belonging to a facet (exact name or `facet/...` prefix).
65fn facet_tokens<B: Postings>(ix: &InfonIndex<B>, facet: &str) -> Vec<String> {
66    let prefix = format!("{facet}/");
67    ix.tokens().filter(|t| *t == facet || t.starts_with(&prefix)).cloned().collect()
68}
69
70/// Redundancy between two facets, measured in **term space** (as `design.py` compares decoder weight
71/// vectors) rather than over union-incidence: the largest cosine between any token of `a` and any token
72/// of `b`. Union-incidence degenerates in a relational corpus — every facet fires on every row, so all
73/// union vectors are all-ones and cosine saturates to 1 regardless of redundancy. Token-level max cosine
74/// instead answers the question that matters: *does some token here duplicate a token there?*
75/// Minimum postings a token needs before it may drive the redundancy estimate. Without a floor, two rare
76/// terms that happen to fire on the *same single document* score cosine 1.0 — noise reading as perfect
77/// redundancy, which silently zeroes a candidate facet's gain.
78pub const MIN_SUPPORT: usize = 3;
79
80fn facet_maxcos<B: Postings>(ix: &InfonIndex<B>, a: &[String], b: &[String], cap: usize) -> f64 {
81    let mut best = 0.0f64;
82    for ta in a.iter().take(cap) {
83        let pa = ix.post(ta);
84        let la = pa.len() as f64;
85        if pa.len() < MIN_SUPPORT {
86            continue; // too rare to say anything about redundancy
87        }
88        for tb in b.iter().take(cap) {
89            let pb = ix.post(tb);
90            let lb = pb.len() as f64;
91            if pb.len() < MIN_SUPPORT {
92                continue;
93            }
94            let cos = pa.and(&pb).len() as f64 / (la * lb).sqrt();
95            if cos > best {
96                best = cos;
97            }
98        }
99    }
100    best
101}
102
103/// Score every facet in the index for MECE fitness. `skip` names structural facets that shouldn't be
104/// judged (e.g. `src`, the per-file provenance tag).
105pub fn report<B: Postings>(ix: &InfonIndex<B>, skip: &[&str]) -> MeceReport {
106    let n = ix.situations();
107    // facet → distinct token count
108    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
109    for tok in ix.tokens() {
110        let f = tok.split('/').next().unwrap_or(tok).to_string();
111        *counts.entry(f).or_default() += 1;
112    }
113    let names: Vec<String> = counts.keys().filter(|f| !skip.contains(&f.as_str())).cloned().collect();
114    let inc: Vec<B> = names.iter().map(|f| facet_incidence(ix, f)).collect();
115    let lens: Vec<f64> = inc.iter().map(|b| b.len() as f64).collect();
116    let toks: Vec<Vec<String>> = names.iter().map(|f| facet_tokens(ix, f)).collect();
117
118    let mut facets = Vec::with_capacity(names.len());
119    for i in 0..names.len() {
120        let mut maxcos = 0.0f64;
121        let mut nearest = String::new();
122        for j in 0..names.len() {
123            if i == j || lens[i] == 0.0 || lens[j] == 0.0 {
124                continue;
125            }
126            let cos = facet_maxcos(ix, &toks[i], &toks[j], 64);
127            if cos > maxcos {
128                maxcos = cos;
129                nearest = names[j].clone();
130            }
131        }
132        let coverage = if n == 0 { 0.0 } else { lens[i] / n as f64 };
133        // A facet matching a single situation earns nothing, whatever its overlap score says.
134        //
135        // The formula alone rewards narrowness: one document out of eight is coverage 0.125, and a facet that
136        // small has too few postings to register as overlapping anything (`MIN_SUPPORT` skips it), so maxcos
137        // comes out 0 and gain is the full 0.125 — comfortably past a 0.05 threshold. Asked for an ontology,
138        // `qwen2.5:0.5b` proposed one facet per person in the corpus and the gate accepted all six.
139        //
140        // Local discovery could never do that: `salient_terms` requires a term in at least two documents, so a
141        // cluster always spans two. Without the same floor here, a model could add categories the deterministic
142        // path is incapable of adding — which is precisely the guarantee `adopt` is supposed to give.
143        // A facet matching a single situation earns nothing, whatever its overlap score says.
144        //
145        // The formula alone rewards narrowness: one document out of eight is coverage 0.125, and a facet that
146        // small has too few postings to register as overlapping anything (`MIN_SUPPORT` skips it), so maxcos
147        // comes out 0 and gain is the full 0.125 — comfortably past a 0.05 threshold. Asked for an ontology,
148        // `qwen2.5:0.5b` proposed one facet per person in the corpus and the gate accepted all six.
149        //
150        // Local discovery could never do that: `salient_terms` requires a term in at least two documents, so a
151        // cluster always spans two. Without the same floor here, a model could add categories the deterministic
152        // path is incapable of adding — which is precisely the guarantee `adopt` is supposed to give.
153        //
154        // There is deliberately NO ceiling on coverage. It is tempting — a curated facet called `industries`
155        // covered 99 of 100 documents and took the highest gain in its set — but high coverage is the
156        // Collectively Exhaustive half of MECE, not a defect. A facet every document carries is ideal when its
157        // VALUES partition them: `powertrain` covers everything and still splits the corpus cleanly into
158        // electric and diesel. Penalising that would punish the property the metric exists to reward. An
159        // over-generic facet is the curator's to drop ("too generic to be a facet"), not the gate's to punish.
160        let gain = if lens[i] < 2.0 { 0.0 } else { coverage * (1.0 - maxcos) };
161        facets.push(FacetScore {
162            facet: names[i].clone(),
163            tokens: counts.get(&names[i]).copied().unwrap_or(0),
164            coverage: round3(coverage),
165            maxcos: round3(maxcos),
166            nearest,
167            gain: round3(gain),
168        });
169    }
170    facets.sort_by(|a, b| b.gain.partial_cmp(&a.gain).unwrap_or(std::cmp::Ordering::Equal));
171    let k = facets.len().max(1) as f64;
172    let mean_coverage = round3(facets.iter().map(|f| f.coverage).sum::<f64>() / k);
173    let mean_maxcos = round3(facets.iter().map(|f| f.maxcos).sum::<f64>() / k);
174    let mean_gain = round3(facets.iter().map(|f| f.gain).sum::<f64>() / k);
175    MeceReport { situations: n, facets, mean_coverage, mean_maxcos, mean_gain }
176}
177
178fn round3(v: f64) -> f64 {
179    (v * 1000.0).round() / 1000.0
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::RoarPostings;
186    use std::collections::HashMap;
187
188    #[test]
189    fn rare_terms_cannot_fake_perfect_redundancy() {
190        // two facets whose only shared signal is a single co-occurring doc must NOT read as redundant
191        let raw = HashMap::from([
192            ("alpha/rare".to_string(), vec![0u32]),
193            ("beta/rare".to_string(), vec![0u32]),
194            // give each facet a well-supported, genuinely disjoint token so coverage is meaningful
195            ("alpha/common".to_string(), vec![0u32, 1, 2, 3]),
196            ("beta/common".to_string(), vec![4, 5, 6, 7]),
197        ]);
198        let ix: InfonIndex<RoarPostings> = InfonIndex::from_postings(raw, 8);
199        let r = report(&ix, &["src"]);
200        let by = |n: &str| r.facets.iter().find(|f| f.facet == n).unwrap().clone();
201        // the single-doc overlap is below MIN_SUPPORT, so it is ignored; the supported tokens are disjoint
202        assert!(by("alpha").maxcos < 1.0, "rare overlap must not saturate: {:?}", by("alpha"));
203        assert!(by("alpha").gain > 0.0, "both facets earn gain: {:?}", by("alpha"));
204        assert!(by("beta").gain > 0.0);
205    }
206
207    #[test]
208    fn gain_rewards_coverage_and_punishes_redundancy() {
209        // `country` and `nation` are near-duplicates (same rows) → both should score poorly on maxcos.
210        // `powertrain` is disjoint-ish and well-covered → best gain. `rare` covers almost nothing.
211        let raw = HashMap::from([
212            ("country/japan".to_string(), vec![0u32, 1, 2, 3]),
213            ("nation/japan".to_string(), vec![0u32, 1, 2, 3]),
214            ("powertrain/electric".to_string(), vec![0, 2]),
215            ("powertrain/diesel".to_string(), vec![1, 3]),
216            ("rare/thing".to_string(), vec![0]),
217        ]);
218        let ix: InfonIndex<RoarPostings> = InfonIndex::from_postings(raw, 4);
219        let r = report(&ix, &["src"]);
220        eprintln!("{}", serde_json::to_string_pretty(&r).unwrap());
221        let by = |n: &str| r.facets.iter().find(|f| f.facet == n).unwrap().clone();
222        // duplicates are maximally redundant → zero gain despite full coverage
223        assert_eq!(by("country").maxcos, 1.0);
224        assert_eq!(by("country").nearest, "nation");
225        assert_eq!(by("country").gain, 0.0);
226        // full coverage, and its overlap with country is partial → positive gain, the best in the set
227        assert_eq!(by("powertrain").coverage, 1.0);
228        assert!(by("powertrain").gain > 0.0, "disjoint well-covered facet must earn gain");
229        assert_eq!(r.facets[0].facet, "powertrain", "best gain = broad coverage + low redundancy");
230        assert!(by("powertrain").maxcos < 1.0, "term-space cosine must not saturate for co-extensive facets");
231        // sparse facet earns little
232        assert!(by("rare").coverage <= 0.25);
233        assert!(by("rare").gain < by("powertrain").gain);
234        // the gate flags the redundant pair
235        let failing: Vec<&str> = r.failing(0.1).iter().map(|f| f.facet.as_str()).collect();
236        assert!(failing.contains(&"country") && failing.contains(&"nation"));
237    }
238}