metrale_circuit/venn/
classify.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-29: Classify one target node against the kernel families (see [`super`] for the
4//! classes): which families can run it, its point in each, the nearest point a compared model
5//! runs (or, failing that, the nearest instantiated point), and the evidence at its point.
6//!
7//! Owner: metrale-circuit (venn).
8//! Invariants:
9//! - A family is a candidate for a node when one of its op specs matches the node (op, role,
10//!   weight and input formats, template parameters, consumer) and the row count is inside the
11//!   rows one launch covers.
12//! - Only nodes of the same op are compared, so both points carry the same parameters.
13//! - The primary finding is the best candidate by (class, differing point parameters, compared
14//!   with a model before a bare point, manifest order); every other candidate whose class is an
15//!   opportunity is kept as a secondary finding.
16
17use std::collections::BTreeMap;
18
19use super::families::{Extract, Families, Family, How, OpSpec, ParamKind, Values};
20use super::{Class, Compared, Diff, Finding, Subject, VennError};
21use crate::ir::{Circuit, Node, NodeIdx, OpKind};
22
23/// 2026-09-29: A node a compared model runs, placed in its family.
24#[derive(Debug, Clone)]
25pub struct Usage<'a> {
26    /// 2026-09-29: Recipe.
27    pub recipe: &'a str,
28    /// 2026-09-29: The node.
29    pub node: &'a Node,
30    /// 2026-09-29: Its family.
31    pub family: &'a Family,
32    /// 2026-09-29: What its plan group launches.
33    pub kernels: String,
34    /// 2026-09-29: Its compile-time and policy values.
35    pub point: Values,
36    /// 2026-09-29: Its runtime values.
37    pub runtime: Values,
38}
39
40/// 2026-09-29: Every node the compared plans run, placed in the family of its plan group.
41pub fn usages<'a>(
42    against: &[Subject<'a>],
43    fams: &'a Families,
44) -> Result<Vec<Usage<'a>>, VennError> {
45    let mut out = Vec::new();
46    for s in against {
47        let Some(plan) = s.plan else {
48            return Err(VennError::Load(format!(
49                "{}: a compared model needs a plan",
50                s.recipe
51            )));
52        };
53        for g in &plan.groups {
54            let kernels = if g.kernels.is_empty() {
55                format!("({} emitter)", g.emitter)
56            } else {
57                g.kernels
58                    .iter()
59                    .map(|k| k.to_string())
60                    .collect::<Vec<_>>()
61                    .join(" + ")
62            };
63            for &n in &g.nodes {
64                let node = &s.circuit.nodes[n];
65                let family = fams
66                    .of_group(&g.kernels, &g.emitter, &node.op)
67                    .ok_or_else(|| VennError::UnmappedKernel {
68                        recipe: s.recipe.to_string(),
69                        node: node.id.clone(),
70                        op: node.op.name(),
71                        kernels: kernels.clone(),
72                    })?;
73                let (point, runtime) = point_of(family, s, node)?;
74                out.push(Usage {
75                    recipe: s.recipe,
76                    node,
77                    family,
78                    kernels: kernels.clone(),
79                    point,
80                    runtime,
81                });
82            }
83        }
84    }
85    Ok(out)
86}
87
88/// 2026-09-29: The families that can run `n` at `rows` rows, in manifest order.
89pub fn candidates<'f>(fams: &'f Families, c: &Circuit, n: &Node, rows: u64) -> Vec<&'f Family> {
90    fams.families
91        .iter()
92        .filter(|f| (f.rows.0..=f.rows.1).contains(&rows))
93        .filter(|f| implements(f, c, n))
94        .collect()
95}
96
97/// 2026-10-01: Family `f` implements node `n` of `c` at some row count.
98pub fn implements(f: &Family, c: &Circuit, n: &Node) -> bool {
99    f.ops.iter().any(|s| op_matches(s, c, n))
100}
101
102fn op_matches(s: &OpSpec, c: &Circuit, n: &Node) -> bool {
103    let role_ok = match n.op {
104        OpKind::Linear(r) => s.roles.is_empty() || s.roles.contains(&r),
105        _ => true,
106    };
107    let input = n.inputs.first().map(|&e| c.edges[e].format);
108    let feeds_ok = s.feeds.is_empty()
109        || n.outputs.first().is_some_and(|&e| {
110            c.edges[e]
111                .consumers
112                .iter()
113                .any(|&k| names(&s.feeds, &c.nodes[k].op))
114        });
115    let after_ok = s.after.is_empty()
116        || n.inputs.first().is_some_and(|&e| {
117            c.edges[e]
118                .producer
119                .is_some_and(|p| names(&s.after, &c.nodes[p].op))
120        });
121    let beside_ok = s.beside.is_empty()
122        || n.inputs.first().is_some_and(|&e| {
123            c.edges[e]
124                .consumers
125                .iter()
126                .any(|&k| c.nodes[k].id != n.id && names(&s.beside, &c.nodes[k].op))
127        });
128    s.names(&n.op)
129        && after_ok
130        && beside_ok
131        && role_ok
132        && (s.weight.is_empty() || n.weight.is_some_and(|w| s.weight.contains(&w)))
133        && (s.activation.is_empty() || input.is_some_and(|a| s.activation.contains(&a)))
134        && s.params.iter().all(|(k, v)| n.params.get(k) == Some(v))
135        && feeds_ok
136}
137
138/// 2026-09-29: `op` is in `set`, by base name (`linear`) or qualified name (`linear:q`).
139fn names(set: &std::collections::BTreeSet<String>, op: &OpKind) -> bool {
140    set.contains(op.base_name()) || set.contains(&op.name())
141}
142
143fn extract(s: &Subject<'_>, n: &Node, ex: &Extract) -> Option<String> {
144    let c = s.circuit;
145    let first_out = || n.outputs.first().map(|&e| &c.edges[e]);
146    match ex {
147        Extract::Dim(d) => c.dims.get(d).map(u64::to_string),
148        Extract::Weight => n.weight.map(|w| w.name()),
149        Extract::Activation => n.inputs.first().map(|&e| c.edges[e].format.name()),
150        Extract::Output => first_out().map(|e| e.format.name()),
151        Extract::Op => Some(n.op.name()),
152        Extract::ConsumerOp => first_out()
153            .and_then(|e| e.consumers.first())
154            .map(|&k| c.nodes[k].op.name()),
155        Extract::Param(k) => n.params.get(k).cloned(),
156        Extract::Setting(k) => s.settings.get(k).cloned(),
157        Extract::InDim => n.inputs.first().map(|&e| c.edges[e].dim_value.to_string()),
158        Extract::OutDim => first_out().map(|e| e.dim_value.to_string()),
159    }
160}
161
162/// 2026-09-29: `n`'s (compile-time and policy, runtime) values in `f`.
163pub fn point_of(f: &Family, s: &Subject<'_>, n: &Node) -> Result<(Values, Values), VennError> {
164    let (mut point, mut runtime) = (Values::new(), Values::new());
165    for p in &f.params {
166        let Some(ex) = p.from.get(n.op.base_name()) else {
167            continue;
168        };
169        let v = extract(s, n, ex)
170            .or_else(|| p.absent.clone())
171            .ok_or_else(|| VennError::MissingValue {
172                family: f.id.clone(),
173                param: p.name.clone(),
174                node: n.id.clone(),
175            })?;
176        if p.kind == ParamKind::Runtime {
177            runtime.insert(p.name.clone(), v);
178        } else {
179            point.insert(p.name.clone(), v);
180        }
181    }
182    Ok((point, runtime))
183}
184
185fn diffs(f: &Family, target: &Values, other: &Values) -> Vec<Diff> {
186    let mut out = Vec::new();
187    for (k, v) in target {
188        let o = other.get(k).cloned().unwrap_or_default();
189        if &o != v {
190            let kind = f.param(k).map_or(ParamKind::Runtime, |p| p.kind);
191            out.push(Diff {
192                param: k.clone(),
193                kind,
194                target: v.clone(),
195                other: o,
196            });
197        }
198    }
199    out
200}
201
202fn opportunity(pointed: &[&Diff]) -> Class {
203    if pointed.iter().all(|d| d.kind == ParamKind::Policy) {
204        Class::PolicyVariant
205    } else {
206        Class::ParameterizationOpportunity
207    }
208}
209
210fn evidence(f: &Family, point: &Values, rows: u64) -> Vec<super::families::EvidenceSource> {
211    f.evidence
212        .iter()
213        .filter(|e| e.rows.contains(&rows) && point.iter().all(|(k, v)| e.point.get(k) == Some(v)))
214        .map(|e| e.source.clone())
215        .collect()
216}
217
218fn finding(
219    f: &Family,
220    s: &Subject<'_>,
221    n: &Node,
222    rows: u64,
223    used: &[Usage<'_>],
224) -> Result<Finding, VennError> {
225    let (point, runtime) = point_of(f, s, n)?;
226    let inst = f
227        .points
228        .iter()
229        .find(|p| point.iter().all(|(k, v)| p.values.get(k) == Some(v)));
230    let ev = evidence(f, &point, rows);
231    let shared = if ev.is_empty() {
232        Class::SharedUnmeasured
233    } else {
234        Class::Shared
235    };
236    let best = used
237        .iter()
238        .filter(|u| std::ptr::eq(u.family, f) && u.node.op.base_name() == n.op.base_name())
239        .map(|u| {
240            let mut d = diffs(f, &point, &u.point);
241            d.extend(diffs(f, &runtime, &u.runtime));
242            (u, d)
243        })
244        .min_by_key(|(_, d)| d.iter().filter(|x| x.kind != ParamKind::Runtime).count());
245    let (class, compared, d) = match best {
246        Some((u, d)) => {
247            let pointed: Vec<&Diff> = d.iter().filter(|x| x.kind != ParamKind::Runtime).collect();
248            let class = if pointed.is_empty() || inst.is_some_and(|p| p.how != How::Copy) {
249                shared
250            } else {
251                opportunity(&pointed)
252            };
253            let compared = Compared::Model {
254                recipe: u.recipe.to_string(),
255                node: u.node.id.clone(),
256                kernels: u.kernels.clone(),
257            };
258            (class, compared, d)
259        }
260        None => {
261            let (q, d) = f
262                .points
263                .iter()
264                .map(|p| (p, diffs(f, &point, &p.values)))
265                .min_by_key(|(_, d)| d.len())
266                .ok_or_else(|| VennError::Load(format!("family `{}` has no points", f.id)))?;
267            let pointed: Vec<&Diff> = d.iter().collect();
268            let class = if pointed.is_empty() {
269                shared
270            } else {
271                opportunity(&pointed)
272            };
273            let compared = Compared::Point {
274                how: q.how,
275                files: q.files.clone(),
276            };
277            (class, compared, d)
278        }
279    };
280    Ok(Finding {
281        family: f.id.clone(),
282        class,
283        point,
284        compared,
285        diffs: d,
286        instantiated: inst.map(|p| p.how),
287        evidence: if matches!(class, Class::Shared | Class::SharedUnmeasured) {
288            ev
289        } else {
290            Vec::new()
291        },
292    })
293}
294
295/// 2026-09-29: The primary finding of target node `idx` (`None`: novel) and the secondary
296/// opportunities.
297pub fn classify_node(
298    target: &Subject<'_>,
299    idx: NodeIdx,
300    rows: u64,
301    used: &[Usage<'_>],
302    fams: &Families,
303) -> Result<(Option<Finding>, Vec<Finding>), VennError> {
304    let n = &target.circuit.nodes[idx];
305    let planned = match target.plan {
306        Some(plan) => {
307            let g = plan
308                .groups
309                .iter()
310                .find(|g| g.nodes.contains(&idx))
311                .ok_or_else(|| {
312                    VennError::Load(format!(
313                        "{}: node `{}` is in no plan group",
314                        target.recipe, n.id
315                    ))
316                })?;
317            let f = fams
318                .of_group(&g.kernels, &g.emitter, &n.op)
319                .ok_or_else(|| VennError::UnmappedKernel {
320                    recipe: target.recipe.to_string(),
321                    node: n.id.clone(),
322                    op: n.op.name(),
323                    kernels: g
324                        .kernels
325                        .iter()
326                        .map(|k| k.to_string())
327                        .collect::<Vec<_>>()
328                        .join(" + "),
329                })?;
330            Some(f)
331        }
332        None => None,
333    };
334    let order: BTreeMap<&str, usize> = fams
335        .families
336        .iter()
337        .enumerate()
338        .map(|(i, f)| (f.id.as_str(), i))
339        .collect();
340    let mut all = Vec::new();
341    let mut fams_for: Vec<&Family> = candidates(fams, target.circuit, n, rows);
342    if let Some(f) = planned
343        && !fams_for.iter().any(|g| std::ptr::eq(*g, f))
344    {
345        fams_for.push(f);
346    }
347    for f in fams_for {
348        all.push(finding(f, target, n, rows, used)?);
349    }
350    let key = |x: &Finding| {
351        (
352            x.class,
353            x.diffs
354                .iter()
355                .filter(|d| d.kind != ParamKind::Runtime)
356                .count(),
357            matches!(x.compared, Compared::Point { .. }),
358            order[x.family.as_str()],
359        )
360    };
361    all.sort_by_key(key);
362    let primary_pos = match planned {
363        Some(f) => all.iter().position(|x| x.family == f.id),
364        None => (!all.is_empty()).then_some(0),
365    };
366    let Some(pos) = primary_pos else {
367        return Ok((None, Vec::new()));
368    };
369    let primary = all.remove(pos);
370    let also = all
371        .into_iter()
372        .filter(|x| {
373            matches!(
374                x.class,
375                Class::PolicyVariant | Class::ParameterizationOpportunity
376            )
377        })
378        .collect();
379    Ok((Some(primary), also))
380}