metrale_circuit/pipeline/
declare.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-02: The DECLARED side: the pipeline each kernel family implements, as
4//! `KERNEL_FAMILIES.toml` states it (`pipeline.<op>`, per point `[[family.point]]
5//! pipeline.<op>`, per kernel `kernel_pipeline."<kernel>".<op>`), and its resolution for one
6//! node of one plan group.
7//!
8//! An op key is an op base name (`linear`, `rms_norm`) or a linear role (`linear:shared_down`)
9//! where one family's roles differ, optionally narrowed to where the node's first input comes
10//! from an op (`linear after act_quant`) or its first output feeds one (`silu_mul feeds
11//! act_quant`), as `[[family.op]]` `after` / `feeds` narrow matching: one kernel can hand the
12//! same product on differently depending on what follows it. Each entry states `in`, every step of the op's vocabulary
13//! ([`super::vocab`]) and `out`, or is the word `"uninstantiated"`: an op the family lists as a
14//! parameterization target (a policy no point instantiates), which no node may resolve to. A
15//! value may name a compile-time or policy parameter of the family as `{param}`, filled from
16//! the node's point (a KV dtype, a quantizer's format).
17//!
18//! Owner: metrale-circuit (pipeline).
19//! Invariants:
20//! - Every op a family implements has a family-level pipeline (for a linear with roles: each
21//!   role, by the role's key or the base `linear` key); points and kernels only override.
22//! - Every entry parses under every point it can apply to, at load; resolution cannot meet a
23//!   value it has not seen parse.
24//! - The most specific declaration wins: a kernel's, then the node's point's, then the family's.
25//!   Two kernels of one group that declare different pipelines for a node are an error.
26
27use std::collections::{BTreeMap, BTreeSet};
28
29use super::vocab::{parse_step, parse_value, step_name, steps_for_base};
30use super::{NodePipeline, Step, StepKind};
31use crate::format::Format;
32use crate::ir::{LinearRole, Node, OpKind};
33use crate::rules::KernelId;
34use crate::venn::families::{OpSpec, Values};
35
36/// 2026-10-02: One declared pipeline as written: values may hold `{param}` placeholders.
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
38pub struct RawPipeline {
39    /// 2026-10-02: Input formats.
40    pub inputs: Vec<String>,
41    /// 2026-10-02: Steps, in vocabulary order.
42    pub steps: Vec<(StepKind, String)>,
43    /// 2026-10-02: Output formats.
44    pub outputs: Vec<String>,
45}
46
47/// 2026-10-02: One declaration of one op.
48#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
49pub enum Entry {
50    /// 2026-10-02: The pipeline the kernels run.
51    Pipeline(RawPipeline),
52    /// 2026-10-02: Listed for the Venn diagram only: no source implements it yet.
53    Uninstantiated,
54}
55
56/// 2026-10-02: Op key to declaration.
57pub type ByOp = BTreeMap<String, Entry>;
58
59/// 2026-10-02: A family's family-level and kernel-level declarations (point-level ones live on
60/// the points, `venn::families::Point::pipeline`).
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct FamilyPipelines {
63    /// 2026-10-02: The family's own.
64    pub family: ByOp,
65    /// 2026-10-02: Kernels that differ.
66    pub kernels: BTreeMap<KernelId, ByOp>,
67}
68
69/// 2026-10-02: Parse one `pipeline` table (op key to `{ in, <steps>, out }` or
70/// `"uninstantiated"`).
71pub fn parse_by_op(tables: &BTreeMap<String, toml::Value>) -> Result<ByOp, String> {
72    let mut out = ByOp::new();
73    for (key, v) in tables {
74        let entry = match v {
75            toml::Value::Table(t) => Entry::Pipeline(raw(key, t)?),
76            toml::Value::String(s) if s == "uninstantiated" => Entry::Uninstantiated,
77            _ => {
78                return Err(format!(
79                    "pipeline `{key}` is neither a table nor \"uninstantiated\""
80                ));
81            }
82        };
83        out.insert(key.clone(), entry);
84    }
85    Ok(out)
86}
87
88/// 2026-10-02: An op key's op (`act_quant:nvfp4/g16` with its format), base op and linear role
89/// (its `after` / `feeds` narrowing checked).
90struct Key<'a> {
91    op: &'a str,
92    base: &'a str,
93    role: Option<LinearRole>,
94}
95
96fn op_name_ok(name: &str) -> bool {
97    match name.split_once(':') {
98        Some(("linear", r)) => LinearRole::parse(r).is_some(),
99        Some(_) => false,
100        None => steps_for_base(name).is_some(),
101    }
102}
103
104fn base_of(key: &str) -> Result<Key<'_>, String> {
105    let (head, after, feeds) = {
106        let (head, feeds) = match key.split_once(" feeds ") {
107            Some((h, f)) => (h, Some(f)),
108            None => (key, None),
109        };
110        match head.split_once(" after ") {
111            Some((h, a)) => (h, Some(a), feeds),
112            None => (head, None, feeds),
113        }
114    };
115    if let Some(bad) = [after, feeds]
116        .into_iter()
117        .flatten()
118        .find(|n| !op_name_ok(n))
119    {
120        return Err(format!("pipeline `{key}`: `{bad}` is no op"));
121    }
122    let (base, role) = match head.split_once(':') {
123        None => (head, None),
124        Some(("linear", r)) => (
125            "linear",
126            Some(
127                LinearRole::parse(r)
128                    .ok_or_else(|| format!("pipeline `{key}`: unknown linear role `{r}`"))?,
129            ),
130        ),
131        Some(("act_quant", f)) if crate::format::Format::parse(f).is_ok() => ("act_quant", None),
132        Some(_) => {
133            return Err(format!(
134                "pipeline `{key}`: only `linear` (a role) and `act_quant` (a format) qualify"
135            ));
136        }
137    };
138    Ok(Key {
139        op: head,
140        base,
141        role,
142    })
143}
144
145fn raw(key: &str, t: &toml::Table) -> Result<RawPipeline, String> {
146    let base = base_of(key)?.base;
147    let steps = steps_for_base(base).ok_or_else(|| format!("pipeline `{key}`: unknown op"))?;
148    let list = |field: &str| -> Result<Vec<String>, String> {
149        let arr = t
150            .get(field)
151            .and_then(|v| v.as_array())
152            .ok_or_else(|| format!("pipeline `{key}` states no `{field}` list"))?;
153        arr.iter()
154            .map(|v| {
155                v.as_str()
156                    .map(str::to_string)
157                    .ok_or_else(|| format!("pipeline `{key}` `{field}`: a non-string format"))
158            })
159            .collect()
160    };
161    for k in t.keys() {
162        let known = k == "in" || k == "out" || parse_step(k).is_some_and(|s| steps.contains(&s));
163        if !known {
164            return Err(format!(
165                "pipeline `{key}`: `{k}` is not a step of `{base}` ({})",
166                steps
167                    .iter()
168                    .map(|s| step_name(*s))
169                    .collect::<Vec<_>>()
170                    .join(", ")
171            ));
172        }
173    }
174    let mut out_steps = Vec::with_capacity(steps.len());
175    for &s in steps {
176        let v = t
177            .get(step_name(s))
178            .and_then(|v| v.as_str())
179            .ok_or_else(|| format!("pipeline `{key}` states no `{}`", step_name(s)))?;
180        out_steps.push((s, v.to_string()));
181    }
182    Ok(RawPipeline {
183        inputs: list("in")?,
184        steps: out_steps,
185        outputs: list("out")?,
186    })
187}
188
189/// 2026-10-02: Check a family's declarations against the ops it implements, its kernels and
190/// its points (`points`: each point's values and own declarations).
191pub fn validate(
192    ops: &[OpSpec],
193    kernels: &[KernelId],
194    decl: &FamilyPipelines,
195    points: &[(&Values, &ByOp)],
196) -> Result<(), String> {
197    let implemented: BTreeSet<&str> = ops.iter().map(|o| o.op.as_str()).collect();
198    let roles_ok = |r: LinearRole| {
199        ops.iter()
200            .any(|o| o.op == "linear" && (o.roles.is_empty() || o.roles.contains(&r)))
201    };
202    let check_keys = |by: &ByOp, whose: &str| -> Result<(), String> {
203        for key in by.keys() {
204            let k = base_of(key)?;
205            let listed = implemented.contains(k.op) || implemented.contains(k.base);
206            if !listed || k.role.is_some_and(|r| !roles_ok(r)) {
207                return Err(format!(
208                    "{whose} declares a pipeline for `{key}`, which the family does not implement"
209                ));
210            }
211        }
212        Ok(())
213    };
214    check_keys(&decl.family, "the family")?;
215    // 2026-10-02: Coverage counts plain keys only (`contains_key` below never names a narrowed
216    // one): a narrowed key overrides where it applies.
217    for o in ops {
218        let covered = if o.op == "linear" && !o.roles.is_empty() {
219            o.roles.iter().all(|r| {
220                decl.family.contains_key(&format!("linear:{}", r.name()))
221                    || decl.family.contains_key("linear")
222            })
223        } else {
224            decl.family.contains_key(&o.op)
225        };
226        if !covered {
227            return Err(format!("no `pipeline.{}` for op `{}`", o.op, o.op));
228        }
229    }
230    for (k, by) in &decl.kernels {
231        if !kernels.contains(k) {
232            return Err(format!(
233                "kernel_pipeline names `{k}`, which is not its kernel"
234            ));
235        }
236        check_keys(by, &format!("kernel_pipeline `{k}`"))?;
237    }
238    for (values, own) in points {
239        check_keys(own, &format!("point {values:?}"))?;
240        let mut all: Vec<(&String, &Entry)> = decl.family.iter().collect();
241        all.extend(own.iter());
242        all.extend(decl.kernels.values().flat_map(|b| b.iter()));
243        for (key, e) in all {
244            if let Entry::Pipeline(p) = e {
245                resolve(p, values)
246                    .map_err(|e| format!("pipeline `{key}` at point {values:?}: {e}"))?;
247            }
248        }
249    }
250    Ok(())
251}
252
253/// 2026-10-02: `text` with each `{param}` replaced by its value in `values`.
254fn substitute(text: &str, values: &Values) -> Result<String, String> {
255    let mut out = String::with_capacity(text.len());
256    let mut rest = text;
257    while let Some(at) = rest.find('{') {
258        out.push_str(&rest[..at]);
259        let end = rest[at..]
260            .find('}')
261            .ok_or_else(|| format!("`{text}` opens a `{{` it does not close"))?;
262        let name = &rest[at + 1..at + end];
263        let v = values
264            .get(name)
265            .ok_or_else(|| format!("`{{{name}}}` is no parameter of the point"))?;
266        out.push_str(v);
267        rest = &rest[at + end + 1..];
268    }
269    out.push_str(rest);
270    Ok(out)
271}
272
273/// 2026-10-02: `p` at the point `values`.
274pub fn resolve(p: &RawPipeline, values: &Values) -> Result<NodePipeline, String> {
275    let fmts = |list: &[String]| -> Result<Vec<Format>, String> {
276        list.iter()
277            .map(|s| Format::parse(&substitute(s, values)?).map_err(|e| e.to_string()))
278            .collect()
279    };
280    let mut steps = Vec::with_capacity(p.steps.len());
281    for (kind, text) in &p.steps {
282        let value = parse_value(*kind, &substitute(text, values)?)
283            .map_err(|e| format!("`{}`: {e}", step_name(*kind)))?;
284        steps.push(Step { kind: *kind, value });
285    }
286    Ok(NodePipeline {
287        inputs: fmts(&p.inputs)?,
288        steps,
289        outputs: fmts(&p.outputs)?,
290    })
291}
292
293/// 2026-10-02: Where a node sits: its op, the op producing its first input and the op of the
294/// first node reading its first output (what a narrowed key matches).
295#[derive(Debug, Clone, Copy)]
296pub struct Site<'a> {
297    /// 2026-10-02: The node.
298    pub node: &'a Node,
299    /// 2026-10-02: The producer of its first input.
300    pub after: Option<&'a OpKind>,
301    /// 2026-10-02: The first reader of its first output.
302    pub feeds: Option<&'a OpKind>,
303}
304
305impl<'a> Site<'a> {
306    /// 2026-10-02: Node `n` of `c`.
307    pub fn of(c: &'a crate::ir::Circuit, n: crate::ir::NodeIdx) -> Self {
308        let node = &c.nodes[n];
309        let after = node
310            .inputs
311            .first()
312            .and_then(|&e| c.edges[e].producer)
313            .map(|p| &c.nodes[p].op);
314        let feeds = node
315            .outputs
316            .first()
317            .and_then(|&e| c.edges[e].consumers.first())
318            .map(|&k| &c.nodes[k].op);
319        Site { node, after, feeds }
320    }
321}
322
323fn names(op: &OpKind) -> Vec<String> {
324    match op {
325        OpKind::Linear(_) | OpKind::ActQuant(_) => vec![op.name(), op.base_name().to_string()],
326        _ => vec![op.base_name().to_string()],
327    }
328}
329
330/// 2026-10-02: The most specific entry of `by` for `site`: role before base, both narrowings
331/// before one, `feeds` before `after`, before none.
332fn lookup<'a>(by: &'a ByOp, site: &Site<'_>) -> Option<&'a Entry> {
333    let around = |o: Option<&OpKind>| o.map(names).unwrap_or_default();
334    let (after, feeds) = (around(site.after), around(site.feeds));
335    for op in names(&site.node.op) {
336        let mut keys = Vec::new();
337        for a in &after {
338            for f in &feeds {
339                keys.push(format!("{op} after {a} feeds {f}"));
340            }
341        }
342        keys.extend(feeds.iter().map(|f| format!("{op} feeds {f}")));
343        keys.extend(after.iter().map(|a| format!("{op} after {a}")));
344        keys.push(op.clone());
345        if let Some(e) = keys.iter().find_map(|k| by.get(k)) {
346            return Some(e);
347        }
348    }
349    None
350}
351
352/// 2026-10-02: The pipeline a family declares for the node at `site`, run by `group` (the plan
353/// group's kernels), at the node's point `point` (the values it carries) among `points` (each
354/// point's values and own declarations).
355pub fn declared_for(
356    decl: &FamilyPipelines,
357    points: &[(&Values, &ByOp)],
358    group: &[KernelId],
359    site: &Site<'_>,
360    point: &Values,
361) -> Result<NodePipeline, String> {
362    let op = site.node.op.name();
363    let matching: Vec<&(&Values, &ByOp)> = points
364        .iter()
365        .filter(|(v, _)| point.iter().all(|(k, x)| v.get(k) == Some(x)))
366        .collect();
367    if matching.is_empty() {
368        return Err(format!(
369            "the node's point {point:?} is not one the family instantiates"
370        ));
371    }
372    let by_kernel: BTreeSet<&Entry> = group
373        .iter()
374        .filter_map(|k| decl.kernels.get(k))
375        .filter_map(|by| lookup(by, site))
376        .collect();
377    let chosen = match by_kernel.into_iter().collect::<Vec<_>>().as_slice() {
378        [] => {
379            let own: BTreeSet<Option<&Entry>> =
380                matching.iter().map(|(_, by)| lookup(by, site)).collect();
381            match own.into_iter().collect::<Vec<_>>().as_slice() {
382                [Some(p)] => *p,
383                [None] => lookup(&decl.family, site)
384                    .ok_or_else(|| format!("no pipeline is declared for `{op}`"))?,
385                _ => {
386                    return Err(format!(
387                        "the points matching {point:?} declare different pipelines for `{op}`"
388                    ));
389                }
390            }
391        }
392        [one] => *one,
393        _ => {
394            return Err(format!(
395                "the group's kernels declare different pipelines for `{op}`"
396            ));
397        }
398    };
399    match chosen {
400        Entry::Pipeline(p) => resolve(p, point),
401        Entry::Uninstantiated => Err(format!(
402            "the family lists `{op}` as a parameterization target that no source implements"
403        )),
404    }
405}
406
407#[cfg(test)]
408#[path = "declare_tests.rs"]
409mod declare_tests;