Skip to main content

kevy_cli/
lint.rs

1//! `lint` — the two questions worth asking about a shape, before and
2//! after the table exists.
3//!
4//! Lessons 1 and 6 of the migration playbook. They are one deliverable
5//! in the plan and two commands here, because they run at different
6//! moments and answer differently.
7//!
8//! **`lint overlap`** is lesson 1, and it is not the check the plan
9//! first described. That plan said to sample a candidate column and see
10//! whether it is single-valued — but a hash field holds one value by
11//! construction, so that check passes forever. The lesson says where
12//! the answer really lives: *"the answer is usually in your
13//! id-derivation or key-construction code, not in the row itself — a
14//! thread can live in several mailboxes."* The symptom of that **is**
15//! in the data, just not in the row: the same name appears under more
16//! than one owner. So this reads the family of owner-keyed collections
17//! and asks whether they intersect. They do ⇒ no column can carry that
18//! dimension, and a membership row is the shape.
19//!
20//! **`lint columns`** is lesson 6, and it can only run **after** the
21//! table is declared — it reads rows. Two columns whose values coincide
22//! on nearly every row are one column copied to get a second sort
23//! order; the answer is another ORDERPATH, which `IDX.ADVISE` names.
24//!
25//! The exit codes differ on purpose. Overlap is an **answer**: a column
26//! cannot carry a multi-valued dimension, so a script should stop.
27//! Coincidence is a **suspicion** — two columns may legitimately agree
28//! — so it reports and exits zero.
29
30use std::collections::BTreeMap;
31use std::io;
32use std::process::ExitCode;
33
34use kevy_resp_client::{Reply, RespClient};
35
36/// What the owner-keyed collections under a prefix look like together.
37pub struct Overlap {
38    /// How many owner collections were read.
39    pub owners: usize,
40    /// Distinct names across all of them.
41    pub names: usize,
42    /// Names that appear under more than one owner.
43    pub shared: usize,
44    /// A few of them, with the owners they appear under.
45    pub examples: Vec<(String, Vec<String>)>,
46    /// Keys under the prefix that are not collections at all — a
47    /// counter or a hash sitting beside the owner sets. Reported so a
48    /// prefix that matched the wrong family is visible.
49    pub skipped: usize,
50}
51
52/// Read every collection under `prefix` and see whether they intersect.
53pub fn overlap(client: &mut RespClient, prefix: &str) -> io::Result<Overlap> {
54    let keys = crate::collections::scan_prefix(client, prefix)?;
55    let mut owners_of: BTreeMap<Vec<u8>, Vec<String>> = BTreeMap::new();
56    let (mut owners, mut skipped) = (0usize, 0usize);
57    for k in &keys {
58        let owner = String::from_utf8_lossy(k).into_owned();
59        // Discovered, not named: a sidecar under the same prefix is a
60        // neighbour, not a failure — but it is counted and reported.
61        let Some(ms) = crate::collections::members_if_collection(client, &owner)? else {
62            skipped += 1;
63            continue;
64        };
65        owners += 1;
66        for m in ms {
67            owners_of.entry(m).or_default().push(owner.clone());
68        }
69    }
70    let mut o = tally(owners, &owners_of);
71    o.skipped = skipped;
72    Ok(o)
73}
74
75/// The question lesson 1 actually asks, as a function: does any name
76/// appear under more than one owner?
77fn tally(owners: usize, owners_of: &BTreeMap<Vec<u8>, Vec<String>>) -> Overlap {
78    let shared: Vec<_> = owners_of.iter().filter(|(_, o)| o.len() > 1).collect();
79    Overlap {
80        owners,
81        skipped: 0,
82        names: owners_of.len(),
83        shared: shared.len(),
84        examples: shared
85            .iter()
86            .take(5)
87            .map(|(n, o)| (String::from_utf8_lossy(n).into_owned(), (*o).clone()))
88            .collect(),
89    }
90}
91
92/// Two columns that agree on most of the rows they both appear in.
93pub struct Coincidence {
94    /// One column.
95    pub a: String,
96    /// The other.
97    pub b: String,
98    /// Rows where both are present and equal.
99    pub same: usize,
100    /// Rows where both are present.
101    pub compared: usize,
102}
103
104impl Coincidence {
105    /// How often they agreed, as a percentage of rows compared.
106    pub fn percent(&self) -> u32 {
107        (self.same * 100).checked_div(self.compared).unwrap_or(0) as u32
108    }
109}
110
111/// Sample rows under a prefix and find column pairs that nearly always
112/// carry the same value.
113pub fn column_pairs(
114    client: &mut RespClient,
115    prefix: &str,
116    sample: usize,
117    threshold: u32,
118) -> io::Result<(usize, Vec<Coincidence>)> {
119    let keys = crate::collections::scan_prefix(client, prefix)?;
120    let mut rows = Vec::new();
121    for k in keys.iter().take(sample) {
122        let row = hgetall(client, k)?;
123        if !row.is_empty() {
124            rows.push(row);
125        }
126    }
127    Ok((rows.len(), coincidences(&rows, threshold)))
128}
129
130/// Column pairs that agree on at least `threshold` percent of the rows
131/// where both are present, worst agreement last.
132fn coincidences(rows: &[BTreeMap<String, Vec<u8>>], threshold: u32) -> Vec<Coincidence> {
133    let mut pairs: BTreeMap<(String, String), (usize, usize)> = BTreeMap::new();
134    for row in rows {
135        let cols: Vec<&String> = row.keys().collect();
136        for (i, a) in cols.iter().enumerate() {
137            for b in &cols[i + 1..] {
138                let e = pairs.entry(((*a).clone(), (*b).clone())).or_insert((0, 0));
139                e.1 += 1;
140                if row[*a] == row[*b] {
141                    e.0 += 1;
142                }
143            }
144        }
145    }
146    let mut out: Vec<Coincidence> = pairs
147        .into_iter()
148        .map(|((a, b), (same, compared))| Coincidence { a, b, same, compared })
149        .filter(|c| c.percent() >= threshold)
150        .collect();
151    out.sort_by(|x, y| y.percent().cmp(&x.percent()).then(x.a.cmp(&y.a)));
152    out
153}
154
155fn hgetall(client: &mut RespClient, key: &[u8]) -> io::Result<BTreeMap<String, Vec<u8>>> {
156    let reply = client.request_borrowed(&[b"HGETALL", key])?;
157    let flat = crate::collections::bulks(reply);
158    Ok(flat
159        .chunks(2)
160        .filter(|c| c.len() == 2)
161        .map(|c| (String::from_utf8_lossy(&c[0]).into_owned(), c[1].clone()))
162        .collect())
163}
164
165/// The declared prefix of a table, from `TABLE.LIST`.
166fn table_prefix(client: &mut RespClient, table: &str) -> io::Result<String> {
167    let Reply::Array(tables) = client.request_borrowed(&[b"TABLE.LIST"])? else {
168        return Err(io::Error::other("TABLE.LIST did not answer with a list"));
169    };
170    for t in &tables {
171        let Reply::Array(items) = t else { continue };
172        let f = crate::doctor::fields(items);
173        let named = f.iter().any(|(k, v)| k == "name" && v == table);
174        if named && let Some((_, p)) = f.iter().find(|(k, _)| k == "prefix") {
175            return Ok(p.clone());
176        }
177    }
178    Err(io::Error::other(format!("no declared table named '{table}'")))
179}
180
181/// `lint overlap --prefix <p>` / `lint columns <table> [--sample N]
182/// [--threshold PCT]`
183pub fn run_lint_cli(args: &[String]) -> ExitCode {
184    let (mut host, mut port) = (crate::DEFAULT_HOST.to_string(), crate::DEFAULT_PORT);
185    let (mut prefix, mut table) = (String::new(), String::new());
186    let (mut sample, mut threshold) = (1000usize, 90u32);
187    let sub = args.first().cloned().unwrap_or_default();
188    let mut i = 1;
189    while i < args.len() {
190        let val = args.get(i + 1);
191        match (args[i].as_str(), val) {
192            ("-h", Some(v)) => host = v.clone(),
193            ("-p", Some(v)) => port = v.parse().unwrap_or(crate::DEFAULT_PORT),
194            ("--prefix", Some(v)) => prefix = v.clone(),
195            ("--sample", Some(v)) => sample = v.parse().unwrap_or(sample),
196            ("--threshold", Some(v)) => threshold = v.parse().unwrap_or(threshold),
197            (other, _) if !other.starts_with('-') && table.is_empty() => {
198                table = other.to_string();
199                i += 1;
200                continue;
201            }
202            _ => {
203                i += 1;
204                continue;
205            }
206        }
207        i += 2;
208    }
209    let mut client = match RespClient::connect(&host, port) {
210        Ok(c) => c,
211        Err(e) => {
212            eprintln!("kevy-cli lint: could not connect to {host}:{port}: {e}");
213            return ExitCode::FAILURE;
214        }
215    };
216    match sub.as_str() {
217        "overlap" => run_overlap(&mut client, &prefix),
218        "columns" => run_columns(&mut client, &table, sample, threshold),
219        other => {
220            eprintln!("kevy-cli lint: unknown subcommand '{other}'");
221            eprintln!("usage: kevy-cli lint overlap --prefix <p>");
222            eprintln!("       kevy-cli lint columns <table> [--sample N] [--threshold PCT]");
223            ExitCode::FAILURE
224        }
225    }
226}
227
228/// Overlap is an answer, not a hint: a column cannot carry a dimension
229/// that names more than one owner, so a non-empty intersection exits
230/// non-zero and a declaring script stops.
231fn run_overlap(client: &mut RespClient, prefix: &str) -> ExitCode {
232    if prefix.is_empty() {
233        eprintln!("kevy-cli lint overlap: --prefix names the family of owner keys");
234        return ExitCode::FAILURE;
235    }
236    let o = match overlap(client, prefix) {
237        Ok(o) => o,
238        Err(e) => {
239            eprintln!("kevy-cli lint overlap: {e}");
240            return ExitCode::FAILURE;
241        }
242    };
243    println!("{} owner(s) under {prefix}, {} distinct name(s)", o.owners, o.names);
244    if o.skipped > 0 {
245        println!("  ({} key(s) under this prefix are not collections and were skipped)", o.skipped);
246    }
247    if o.owners == 0 {
248        println!("no collection under {prefix} — is that the right prefix?");
249        return ExitCode::FAILURE;
250    }
251    if o.shared == 0 {
252        println!("no name appears under more than one owner — a column can carry this dimension");
253        return ExitCode::SUCCESS;
254    }
255    println!("{} name(s) appear under more than one owner:", o.shared);
256    for (name, owners) in &o.examples {
257        println!("  {name}  →  {}", owners.join(", "));
258    }
259    println!(
260        "this dimension is multi-valued, so no column can hold it — model a membership row \
261         per (owner, item) and let an ORDERPATH sort it"
262    );
263    ExitCode::FAILURE
264}
265
266/// Coincidence is a suspicion — two columns may legitimately agree —
267/// so this reports and exits zero whatever it finds.
268fn run_columns(client: &mut RespClient, table: &str, sample: usize, threshold: u32) -> ExitCode {
269    if table.is_empty() {
270        eprintln!("kevy-cli lint columns: name a declared table");
271        return ExitCode::FAILURE;
272    }
273    let prefix = match table_prefix(client, table) {
274        Ok(p) => p,
275        Err(e) => {
276            eprintln!("kevy-cli lint columns: {e}");
277            return ExitCode::FAILURE;
278        }
279    };
280    let (rows, found) = match column_pairs(client, &prefix, sample, threshold) {
281        Ok(r) => r,
282        Err(e) => {
283            eprintln!("kevy-cli lint columns: {e}");
284            return ExitCode::FAILURE;
285        }
286    };
287    println!("{table}: {rows} row(s) sampled under {prefix}");
288    if found.is_empty() {
289        println!("no two columns agree on {threshold}% or more of them");
290        return ExitCode::SUCCESS;
291    }
292    for c in &found {
293        println!("  {} and {} agree on {}% ({}/{})", c.a, c.b, c.percent(), c.same, c.compared);
294    }
295    println!(
296        "a column copied to get a second sort order is the shape lesson 6 warns about — \
297         the answer is another ORDERPATH; ask IDX.ADVISE which one"
298    );
299    ExitCode::SUCCESS
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn under(pairs: &[(&str, &[&str])]) -> BTreeMap<Vec<u8>, Vec<String>> {
307        let mut m: BTreeMap<Vec<u8>, Vec<String>> = BTreeMap::new();
308        for (name, owners) in pairs {
309            m.insert(name.as_bytes().to_vec(), owners.iter().map(|o| o.to_string()).collect());
310        }
311        m
312    }
313
314    fn row(fields: &[(&str, &str)]) -> BTreeMap<String, Vec<u8>> {
315        fields.iter().map(|(k, v)| (k.to_string(), v.as_bytes().to_vec())).collect()
316    }
317
318    /// The mail system's case: a thread that lives in several
319    /// mailboxes. One name under two owners is the whole answer.
320    #[test]
321    fn a_name_under_two_owners_is_the_multi_valued_signal() {
322        let o = tally(2, &under(&[("t1", &["m:1"]), ("t2", &["m:1", "m:2"]), ("t3", &["m:2"])]));
323        assert_eq!((o.names, o.shared), (3, 1));
324        assert_eq!(o.examples[0].0, "t2");
325        assert_eq!(o.examples[0].1, ["m:1", "m:2"]);
326    }
327
328    /// Owners that share nothing mean a column *can* carry the
329    /// dimension — the answer this check exists to give when it is yes.
330    #[test]
331    fn disjoint_owners_leave_nothing_shared() {
332        let o = tally(2, &under(&[("x", &["a"]), ("y", &["b"])]));
333        assert_eq!(o.shared, 0);
334    }
335
336    /// Lesson 6's shape: one column copied to get a second sort order.
337    /// Drift in a few rows must not hide it, so the threshold is a
338    /// percentage rather than "always equal".
339    #[test]
340    fn a_copied_column_shows_up_below_perfect_agreement() {
341        let mut rows: Vec<_> =
342            (0..9).map(|i| row(&[("a", "1"), ("b", "1"), ("c", &format!("{i}"))])).collect();
343        rows.push(row(&[("a", "1"), ("b", "2"), ("c", "9")]));
344        let found = coincidences(&rows, 90);
345        let named: Vec<&str> = found.iter().map(|c| c.a.as_str()).collect();
346        assert_eq!(found.len(), 1, "only a/b agree enough, got {named:?}");
347        assert_eq!((found[0].a.as_str(), found[0].b.as_str()), ("a", "b"));
348        assert_eq!(found[0].percent(), 90);
349    }
350
351    /// Above what was actually found, nothing is reported — the
352    /// threshold is the caller's, not a fixed opinion.
353    #[test]
354    fn a_threshold_above_the_agreement_reports_nothing() {
355        let rows = vec![row(&[("a", "1"), ("b", "1")]), row(&[("a", "1"), ("b", "2")])];
356        assert!(coincidences(&rows, 60).is_empty());
357        assert_eq!(coincidences(&rows, 50).len(), 1);
358    }
359}