Skip to main content

sc_neurocore_engine/ir/
parser.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Text-format parser for SC IR graphs
8
9//! Text-format parser for SC IR graphs.
10//!
11//! Parses the format produced by `printer::print()`. The parser is
12//! intentionally simple (line-oriented) since the format is machine-
13//! generated. A future version may support full MLIR-compatible syntax.
14
15use crate::ir::graph::*;
16
17/// Parse error with line number.
18#[derive(Debug, Clone)]
19pub struct ParseError {
20    pub line: usize,
21    pub message: String,
22}
23
24impl std::fmt::Display for ParseError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "line {}: {}", self.line, self.message)
27    }
28}
29
30/// Parse an SC IR text file into a graph.
31///
32/// This parser handles the subset of the text format needed for
33/// round-trip testing: `sc.input`, `sc.output`, `sc.constant`, `sc.encode`,
34/// `sc.and`, `sc.popcount`, and `sc.dense_forward`.
35///
36/// Complex ops (`LifStep`, `Scale`, `Offset`, `DivConst`) are parsed by
37/// recognizing the op name and extracting key-value parameters.
38pub fn parse(text: &str) -> Result<ScGraph, ParseError> {
39    let lines: Vec<&str> = text.lines().collect();
40    if lines.is_empty() {
41        return Err(ParseError {
42            line: 0,
43            message: "empty input".to_string(),
44        });
45    }
46
47    // Line 0: "sc.graph @name {"
48    let first = lines[0].trim();
49    let name = first
50        .strip_prefix("sc.graph @")
51        .and_then(|s| s.strip_suffix(" {"))
52        .ok_or_else(|| ParseError {
53            line: 1,
54            message: "expected 'sc.graph @name {'".to_string(),
55        })?
56        .to_string();
57
58    let mut graph = ScGraph::new(name);
59
60    for (line_idx, line) in lines.iter().enumerate().skip(1) {
61        let trimmed = line.trim();
62        if trimmed == "}" || trimmed.is_empty() {
63            continue;
64        }
65
66        if trimmed.contains("= sc.input") {
67            parse_input(trimmed, &mut graph, line_idx + 1)?;
68        } else if trimmed.starts_with("sc.output") {
69            parse_output(trimmed, &mut graph, line_idx + 1)?;
70        } else if trimmed.contains("= sc.constant") {
71            parse_constant(trimmed, &mut graph, line_idx + 1)?;
72        } else if trimmed.contains("= sc.encode") {
73            parse_encode(trimmed, &mut graph, line_idx + 1)?;
74        } else if trimmed.contains("= sc.xor") {
75            parse_xor(trimmed, &mut graph, line_idx + 1)?;
76        } else if trimmed.contains("= sc.and") {
77            parse_and(trimmed, &mut graph, line_idx + 1)?;
78        } else if trimmed.contains("= sc.reduce") {
79            parse_reduce(trimmed, &mut graph, line_idx + 1)?;
80        } else if trimmed.contains("= sc.popcount") {
81            parse_popcount(trimmed, &mut graph, line_idx + 1)?;
82        } else if trimmed.contains("= sc.dense_forward") {
83            parse_dense_forward(trimmed, &mut graph, line_idx + 1)?;
84        } else if trimmed.contains("= sc.graph_forward") {
85            parse_graph_forward(trimmed, &mut graph, line_idx + 1)?;
86        } else if trimmed.contains("= sc.softmax_attention") {
87            parse_softmax_attention(trimmed, &mut graph, line_idx + 1)?;
88        } else if trimmed.contains("= sc.kuramoto_step") {
89            parse_kuramoto_step(trimmed, &mut graph, line_idx + 1)?;
90        } else if trimmed.contains("= sc.lif_step") {
91            parse_lif_step(trimmed, &mut graph, line_idx + 1)?;
92        } else if trimmed.contains("= sc.scale") {
93            parse_scale(trimmed, &mut graph, line_idx + 1)?;
94        } else if trimmed.contains("= sc.offset") {
95            parse_offset(trimmed, &mut graph, line_idx + 1)?;
96        } else if trimmed.contains("= sc.div_const") {
97            parse_div_const(trimmed, &mut graph, line_idx + 1)?;
98        } else {
99            return Err(ParseError {
100                line: line_idx + 1,
101                message: format!("unrecognised op: {}", trimmed),
102            });
103        }
104    }
105
106    Ok(graph)
107}
108
109// Helpers
110
111fn parse_value_id(s: &str) -> Result<ValueId, String> {
112    let s = s.trim().trim_matches(',');
113    s.strip_prefix('%')
114        .and_then(|n| n.parse::<u32>().ok())
115        .map(ValueId)
116        .ok_or_else(|| format!("invalid ValueId: '{}'", s))
117}
118
119fn parse_type(s: &str) -> Result<ScType, String> {
120    let s = s.trim();
121    if s == "rate" {
122        return Ok(ScType::Rate);
123    }
124    if s == "bool" {
125        return Ok(ScType::Bool);
126    }
127    if s == "u64" {
128        return Ok(ScType::UInt { width: 64 });
129    }
130    if let Some(w) = s.strip_prefix('u') {
131        if let Ok(width) = w.parse::<u32>() {
132            return Ok(ScType::UInt { width });
133        }
134    }
135    if let Some(w) = s.strip_prefix('i') {
136        if let Ok(width) = w.parse::<u32>() {
137            return Ok(ScType::SInt { width });
138        }
139    }
140    if let Some(inner) = s
141        .strip_prefix("bitstream<")
142        .and_then(|r| r.strip_suffix('>'))
143    {
144        let length = inner.parse::<usize>().map_err(|e| e.to_string())?;
145        return Ok(ScType::Bitstream { length });
146    }
147    if s == "bitstream" {
148        return Ok(ScType::Bitstream { length: 0 }); // unspecified
149    }
150    if let Some(inner) = s.strip_prefix("fixed<").and_then(|r| r.strip_suffix('>')) {
151        let parts: Vec<&str> = inner.split(',').collect();
152        if parts.len() == 2 {
153            let width = parts[0].trim().parse::<u32>().map_err(|e| e.to_string())?;
154            let frac = parts[1].trim().parse::<u32>().map_err(|e| e.to_string())?;
155            return Ok(ScType::FixedPoint { width, frac });
156        }
157    }
158    if let Some(inner) = s.strip_prefix("vec<").and_then(|r| r.strip_suffix('>')) {
159        // "bool,7" -> Vec<Bool, 7>
160        if let Some(comma_pos) = inner.rfind(',') {
161            let elem_str = &inner[..comma_pos];
162            let count_str = inner[comma_pos + 1..].trim();
163            let element = parse_type(elem_str)?;
164            let count = count_str.parse::<usize>().map_err(|e| e.to_string())?;
165            return Ok(ScType::Vec {
166                element: Box::new(element),
167                count,
168            });
169        }
170    }
171    Err(format!("unrecognised type: '{}'", s))
172}
173
174fn extract_kv(text: &str, key: &str) -> Option<String> {
175    text.find(&format!("{}=", key)).map(|start| {
176        let rest = &text[start + key.len() + 1..];
177        let end = rest.find([',', ' ', ':']).unwrap_or(rest.len());
178        rest[..end].to_string()
179    })
180}
181
182fn make_err(line: usize, msg: impl Into<String>) -> ParseError {
183    ParseError {
184        line,
185        message: msg.into(),
186    }
187}
188
189/// Parse a floating-point literal, rejecting non-finite values (`NaN`, `inf`, `-inf`).
190///
191/// Non-finite values are meaningless as SC constants/parameters, and `NaN` in particular
192/// breaks the parse/print round-trip because `NaN != NaN`: a graph carrying `NaN` can never
193/// compare equal to its own re-parse. Rejecting them at parse time keeps the text format
194/// total under round-tripping.
195fn parse_finite_f64(s: &str, line: usize) -> Result<f64, ParseError> {
196    let value: f64 = s
197        .parse()
198        .map_err(|e: std::num::ParseFloatError| make_err(line, e.to_string()))?;
199    if !value.is_finite() {
200        return Err(make_err(line, format!("non-finite float literal '{s}'")));
201    }
202    Ok(value)
203}
204
205fn parse_scalar_constant(val_str: &str, ty: &ScType, line: usize) -> Result<ScConst, ParseError> {
206    if val_str.contains('.') || matches!(ty, ScType::Rate) {
207        return parse_finite_f64(val_str, line).map(ScConst::F64);
208    }
209    match ty {
210        ScType::FixedPoint { .. } | ScType::SInt { .. } => val_str
211            .parse::<i64>()
212            .map(ScConst::I64)
213            .map_err(|e| make_err(line, e.to_string())),
214        _ => val_str
215            .parse::<u64>()
216            .map(ScConst::U64)
217            .map_err(|e| make_err(line, e.to_string())),
218    }
219}
220
221fn parse_vector_constant(val_str: &str, line: usize) -> Result<ScConst, ParseError> {
222    let inner = val_str
223        .strip_prefix('[')
224        .and_then(|s| s.strip_suffix(']'))
225        .ok_or_else(|| make_err(line, "malformed vector constant"))?;
226    if inner.trim().is_empty() {
227        return Ok(ScConst::I64Vec(Vec::new()));
228    }
229    let is_float = inner.split(',').any(|part| part.trim().contains('.'));
230    if is_float {
231        let mut out = Vec::new();
232        for token in inner.split(',') {
233            out.push(parse_finite_f64(token.trim(), line)?);
234        }
235        Ok(ScConst::F64Vec(out))
236    } else {
237        let mut out = Vec::new();
238        for token in inner.split(',') {
239            out.push(
240                token
241                    .trim()
242                    .parse::<i64>()
243                    .map_err(|e| make_err(line, e.to_string()))?,
244            );
245        }
246        Ok(ScConst::I64Vec(out))
247    }
248}
249
250// Op parsers
251
252fn parse_input(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
253    // %0 = sc.input "x_in" : rate
254    let parts: Vec<&str> = text.splitn(2, "= sc.input").collect();
255    if parts.len() != 2 {
256        return Err(make_err(line, "malformed sc.input"));
257    }
258    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
259    let rest = parts[1].trim();
260
261    // Extract name between quotes
262    let name_start = rest
263        .find('"')
264        .ok_or_else(|| make_err(line, "missing name"))?;
265    let name_end = rest[name_start + 1..]
266        .find('"')
267        .ok_or_else(|| make_err(line, "unterminated name"))?;
268    let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
269
270    // Extract type after ':'
271    let colon_pos = rest
272        .rfind(':')
273        .ok_or_else(|| make_err(line, "missing type"))?;
274    let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
275
276    graph.next_id = graph.next_id.max(id.0 + 1);
277    graph.push(ScOp::Input { id, name, ty });
278    Ok(())
279}
280
281fn parse_output(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
282    // sc.output "result" %5
283    let rest = text.strip_prefix("sc.output").unwrap_or(text).trim();
284    let name_start = rest
285        .find('"')
286        .ok_or_else(|| make_err(line, "missing name"))?;
287    let name_end = rest[name_start + 1..]
288        .find('"')
289        .ok_or_else(|| make_err(line, "unterminated name"))?;
290    let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
291
292    let after_name = rest[name_start + 1 + name_end + 1..].trim();
293    let source = parse_value_id(after_name).map_err(|e| make_err(line, e))?;
294
295    let id = ValueId(graph.next_id);
296    graph.next_id += 1;
297    graph.push(ScOp::Output { id, name, source });
298    Ok(())
299}
300
301fn parse_constant(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
302    let parts: Vec<&str> = text.splitn(2, "= sc.constant").collect();
303    if parts.len() != 2 {
304        return Err(make_err(line, "malformed sc.constant"));
305    }
306    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
307    let rest = parts[1].trim();
308
309    let colon_pos = rest
310        .rfind(':')
311        .ok_or_else(|| make_err(line, "missing type"))?;
312    let val_str = rest[..colon_pos].trim();
313    let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
314
315    let value = if val_str.starts_with('[') {
316        parse_vector_constant(val_str, line)?
317    } else {
318        parse_scalar_constant(val_str, &ty, line)?
319    };
320
321    graph.next_id = graph.next_id.max(id.0 + 1);
322    graph.push(ScOp::Constant { id, value, ty });
323    Ok(())
324}
325
326fn parse_encode(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
327    let parts: Vec<&str> = text.splitn(2, "= sc.encode").collect();
328    if parts.len() != 2 {
329        return Err(make_err(line, "malformed sc.encode"));
330    }
331    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
332    let rest = parts[1].trim();
333
334    // First token after "= sc.encode " is the prob operand.
335    let tokens: Vec<&str> = rest.split_whitespace().collect();
336    let prob = parse_value_id(
337        tokens
338            .first()
339            .ok_or_else(|| make_err(line, "missing prob"))?,
340    )
341    .map_err(|e| make_err(line, e))?;
342
343    let length_str = extract_kv(rest, "length").ok_or_else(|| make_err(line, "missing length"))?;
344    let length = length_str
345        .parse::<usize>()
346        .map_err(|e| make_err(line, e.to_string()))?;
347
348    let seed_str = extract_kv(rest, "seed").ok_or_else(|| make_err(line, "missing seed"))?;
349    let seed = if seed_str.starts_with("0x") || seed_str.starts_with("0X") {
350        u16::from_str_radix(&seed_str[2..], 16).map_err(|e| make_err(line, e.to_string()))?
351    } else {
352        seed_str
353            .parse::<u16>()
354            .map_err(|e| make_err(line, e.to_string()))?
355    };
356
357    graph.next_id = graph.next_id.max(id.0 + 1);
358    graph.push(ScOp::Encode {
359        id,
360        prob,
361        length,
362        seed,
363    });
364    Ok(())
365}
366
367fn parse_and(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
368    let parts: Vec<&str> = text.splitn(2, "= sc.and").collect();
369    if parts.len() != 2 {
370        return Err(make_err(line, "malformed sc.and"));
371    }
372    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
373    let rest = parts[1].trim();
374    let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
375    if operands.len() < 2 {
376        return Err(make_err(line, "sc.and needs 2 operands"));
377    }
378    let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
379    let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
380
381    graph.next_id = graph.next_id.max(id.0 + 1);
382    graph.push(ScOp::BitwiseAnd { id, lhs, rhs });
383    Ok(())
384}
385
386fn parse_popcount(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
387    let parts: Vec<&str> = text.splitn(2, "= sc.popcount").collect();
388    if parts.len() != 2 {
389        return Err(make_err(line, "malformed sc.popcount"));
390    }
391    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
392    let rest = parts[1].trim();
393    let input_str = rest.split(':').next().unwrap_or("").trim();
394    let input = parse_value_id(input_str).map_err(|e| make_err(line, e))?;
395
396    graph.next_id = graph.next_id.max(id.0 + 1);
397    graph.push(ScOp::Popcount { id, input });
398    Ok(())
399}
400
401fn parse_dense_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
402    let parts: Vec<&str> = text.splitn(2, "= sc.dense_forward").collect();
403    if parts.len() != 2 {
404        return Err(make_err(line, "malformed sc.dense_forward"));
405    }
406    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
407    let rest = parts[1].trim();
408
409    let tokens: Vec<&str> = rest.split_whitespace().collect();
410    let inputs = parse_value_id(
411        tokens
412            .first()
413            .ok_or_else(|| make_err(line, "missing inputs"))?,
414    )
415    .map_err(|e| make_err(line, e))?;
416
417    let weights_str =
418        extract_kv(rest, "weights").ok_or_else(|| make_err(line, "missing weights"))?;
419    let weights = parse_value_id(&weights_str).map_err(|e| make_err(line, e))?;
420
421    let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
422    let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
423
424    let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
425    let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
426
427    let ni = extract_kv(rest, "ni")
428        .and_then(|s| s.parse::<usize>().ok())
429        .unwrap_or(3);
430    let nn = extract_kv(rest, "nn")
431        .and_then(|s| s.parse::<usize>().ok())
432        .unwrap_or(7);
433    let len = extract_kv(rest, "len")
434        .and_then(|s| s.parse::<usize>().ok())
435        .unwrap_or(1024);
436
437    let params = DenseParams {
438        n_inputs: ni,
439        n_neurons: nn,
440        stream_length: len,
441        ..DenseParams::default()
442    };
443
444    graph.next_id = graph.next_id.max(id.0 + 1);
445    graph.push(ScOp::DenseForward {
446        id,
447        inputs,
448        weights,
449        leak,
450        gain,
451        params,
452    });
453    Ok(())
454}
455
456fn parse_lif_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
457    let parts: Vec<&str> = text.splitn(2, "= sc.lif_step").collect();
458    if parts.len() != 2 {
459        return Err(make_err(line, "malformed sc.lif_step"));
460    }
461    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
462    let rest = parts[1].trim();
463
464    let tokens: Vec<&str> = rest.split_whitespace().collect();
465    let current = parse_value_id(
466        tokens
467            .first()
468            .ok_or_else(|| make_err(line, "missing current"))?,
469    )
470    .map_err(|e| make_err(line, e))?;
471
472    let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
473    let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
474
475    let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
476    let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
477
478    let noise_str = extract_kv(rest, "noise").ok_or_else(|| make_err(line, "missing noise"))?;
479    let noise = parse_value_id(&noise_str).map_err(|e| make_err(line, e))?;
480
481    let dw = extract_kv(rest, "dw")
482        .and_then(|s| s.parse::<u32>().ok())
483        .unwrap_or(16);
484    let frac = extract_kv(rest, "frac")
485        .and_then(|s| s.parse::<u32>().ok())
486        .unwrap_or(8);
487    let vt = extract_kv(rest, "vt")
488        .and_then(|s| s.parse::<i64>().ok())
489        .unwrap_or(256);
490    let rp = extract_kv(rest, "rp")
491        .and_then(|s| s.parse::<u32>().ok())
492        .unwrap_or(2);
493
494    let params = LifParams {
495        data_width: dw,
496        fraction: frac,
497        v_threshold: vt,
498        refractory_period: rp,
499        ..LifParams::default()
500    };
501
502    graph.next_id = graph.next_id.max(id.0 + 1);
503    graph.push(ScOp::LifStep {
504        id,
505        current,
506        leak,
507        gain,
508        noise,
509        params,
510    });
511    Ok(())
512}
513
514fn parse_scale(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
515    let parts: Vec<&str> = text.splitn(2, "= sc.scale").collect();
516    if parts.len() != 2 {
517        return Err(make_err(line, "malformed sc.scale"));
518    }
519    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
520    let rest = parts[1].trim();
521
522    let tokens: Vec<&str> = rest.split_whitespace().collect();
523    let input = parse_value_id(
524        tokens
525            .first()
526            .ok_or_else(|| make_err(line, "missing input"))?,
527    )
528    .map_err(|e| make_err(line, e))?;
529
530    let factor_str = extract_kv(rest, "factor").ok_or_else(|| make_err(line, "missing factor"))?;
531    let factor = parse_finite_f64(&factor_str, line)?;
532
533    graph.next_id = graph.next_id.max(id.0 + 1);
534    graph.push(ScOp::Scale { id, input, factor });
535    Ok(())
536}
537
538fn parse_offset(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
539    let parts: Vec<&str> = text.splitn(2, "= sc.offset").collect();
540    if parts.len() != 2 {
541        return Err(make_err(line, "malformed sc.offset"));
542    }
543    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
544    let rest = parts[1].trim();
545
546    let tokens: Vec<&str> = rest.split_whitespace().collect();
547    let input = parse_value_id(
548        tokens
549            .first()
550            .ok_or_else(|| make_err(line, "missing input"))?,
551    )
552    .map_err(|e| make_err(line, e))?;
553
554    let offset_str = extract_kv(rest, "offset").ok_or_else(|| make_err(line, "missing offset"))?;
555    let offset = parse_finite_f64(&offset_str, line)?;
556
557    graph.next_id = graph.next_id.max(id.0 + 1);
558    graph.push(ScOp::Offset { id, input, offset });
559    Ok(())
560}
561
562fn parse_xor(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
563    let parts: Vec<&str> = text.splitn(2, "= sc.xor").collect();
564    if parts.len() != 2 {
565        return Err(make_err(line, "malformed sc.xor"));
566    }
567    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
568    let rest = parts[1].trim();
569    let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
570    if operands.len() < 2 {
571        return Err(make_err(line, "sc.xor needs 2 operands"));
572    }
573    let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
574    let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
575
576    graph.next_id = graph.next_id.max(id.0 + 1);
577    graph.push(ScOp::BitwiseXor { id, lhs, rhs });
578    Ok(())
579}
580
581fn parse_reduce(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
582    let parts: Vec<&str> = text.splitn(2, "= sc.reduce").collect();
583    if parts.len() != 2 {
584        return Err(make_err(line, "malformed sc.reduce"));
585    }
586    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
587    let rest = parts[1].trim();
588
589    let tokens: Vec<&str> = rest.split_whitespace().collect();
590    let input = parse_value_id(
591        tokens
592            .first()
593            .ok_or_else(|| make_err(line, "missing input"))?,
594    )
595    .map_err(|e| make_err(line, e))?;
596
597    let mode_str = extract_kv(rest, "mode").unwrap_or_else(|| "sum".to_string());
598    let mode = match mode_str.as_str() {
599        "max" => ReduceMode::Max,
600        _ => ReduceMode::Sum,
601    };
602
603    graph.next_id = graph.next_id.max(id.0 + 1);
604    graph.push(ScOp::Reduce { id, input, mode });
605    Ok(())
606}
607
608fn parse_graph_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
609    let parts: Vec<&str> = text.splitn(2, "= sc.graph_forward").collect();
610    if parts.len() != 2 {
611        return Err(make_err(line, "malformed sc.graph_forward"));
612    }
613    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
614    let rest = parts[1].trim();
615
616    let tokens: Vec<&str> = rest.split_whitespace().collect();
617    let features = parse_value_id(
618        tokens
619            .first()
620            .ok_or_else(|| make_err(line, "missing features"))?,
621    )
622    .map_err(|e| make_err(line, e))?;
623
624    let adj_str = extract_kv(rest, "adj").ok_or_else(|| make_err(line, "missing adj"))?;
625    let adjacency = parse_value_id(&adj_str).map_err(|e| make_err(line, e))?;
626
627    let n_nodes = extract_kv(rest, "nodes")
628        .and_then(|s| s.parse::<usize>().ok())
629        .unwrap_or(16);
630    let n_features = extract_kv(rest, "features")
631        .and_then(|s| s.parse::<usize>().ok())
632        .unwrap_or(4);
633
634    graph.next_id = graph.next_id.max(id.0 + 1);
635    graph.push(ScOp::GraphForward {
636        id,
637        features,
638        adjacency,
639        n_nodes,
640        n_features,
641    });
642    Ok(())
643}
644
645fn parse_softmax_attention(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
646    let parts: Vec<&str> = text.splitn(2, "= sc.softmax_attention").collect();
647    if parts.len() != 2 {
648        return Err(make_err(line, "malformed sc.softmax_attention"));
649    }
650    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
651    let rest = parts[1].trim();
652
653    let before_colon = rest.split(':').next().unwrap_or("");
654    let operands: Vec<&str> = before_colon.split(',').collect();
655    if operands.len() < 3 {
656        return Err(make_err(line, "sc.softmax_attention needs q, k, v"));
657    }
658    let q = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
659    let k = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
660
661    // v operand may include dim_k= key-value pair
662    let v_token = operands[2].split_whitespace().next().unwrap_or("");
663    let v = parse_value_id(v_token).map_err(|e| make_err(line, e))?;
664
665    let dim_k = extract_kv(rest, "dim_k")
666        .and_then(|s| s.parse::<usize>().ok())
667        .unwrap_or(64);
668
669    graph.next_id = graph.next_id.max(id.0 + 1);
670    graph.push(ScOp::SoftmaxAttention { id, q, k, v, dim_k });
671    Ok(())
672}
673
674fn parse_kuramoto_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
675    let parts: Vec<&str> = text.splitn(2, "= sc.kuramoto_step").collect();
676    if parts.len() != 2 {
677        return Err(make_err(line, "malformed sc.kuramoto_step"));
678    }
679    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
680    let rest = parts[1].trim();
681
682    let tokens: Vec<&str> = rest.split_whitespace().collect();
683    let phases = parse_value_id(
684        tokens
685            .first()
686            .ok_or_else(|| make_err(line, "missing phases"))?,
687    )
688    .map_err(|e| make_err(line, e))?;
689
690    let omega_str = extract_kv(rest, "omega").ok_or_else(|| make_err(line, "missing omega"))?;
691    let omega = parse_value_id(&omega_str).map_err(|e| make_err(line, e))?;
692
693    let k_str = extract_kv(rest, "K").ok_or_else(|| make_err(line, "missing K"))?;
694    let coupling = parse_value_id(&k_str).map_err(|e| make_err(line, e))?;
695
696    let dt = match extract_kv(rest, "dt") {
697        Some(s) => parse_finite_f64(&s, line)?,
698        None => 0.01,
699    };
700
701    graph.next_id = graph.next_id.max(id.0 + 1);
702    graph.push(ScOp::KuramotoStep {
703        id,
704        phases,
705        omega,
706        coupling,
707        dt,
708    });
709    Ok(())
710}
711
712fn parse_div_const(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
713    let parts: Vec<&str> = text.splitn(2, "= sc.div_const").collect();
714    if parts.len() != 2 {
715        return Err(make_err(line, "malformed sc.div_const"));
716    }
717    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
718    let rest = parts[1].trim();
719
720    let tokens: Vec<&str> = rest.split_whitespace().collect();
721    let input = parse_value_id(
722        tokens
723            .first()
724            .ok_or_else(|| make_err(line, "missing input"))?,
725    )
726    .map_err(|e| make_err(line, e))?;
727
728    let divisor_str =
729        extract_kv(rest, "divisor").ok_or_else(|| make_err(line, "missing divisor"))?;
730    let divisor = divisor_str
731        .parse::<u64>()
732        .map_err(|e| make_err(line, e.to_string()))?;
733
734    graph.next_id = graph.next_id.max(id.0 + 1);
735    graph.push(ScOp::DivConst { id, input, divisor });
736    Ok(())
737}