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
189fn parse_scalar_constant(val_str: &str, ty: &ScType, line: usize) -> Result<ScConst, ParseError> {
190    if val_str.contains('.') || matches!(ty, ScType::Rate) {
191        return val_str
192            .parse::<f64>()
193            .map(ScConst::F64)
194            .map_err(|e| make_err(line, e.to_string()));
195    }
196    match ty {
197        ScType::FixedPoint { .. } | ScType::SInt { .. } => val_str
198            .parse::<i64>()
199            .map(ScConst::I64)
200            .map_err(|e| make_err(line, e.to_string())),
201        _ => val_str
202            .parse::<u64>()
203            .map(ScConst::U64)
204            .map_err(|e| make_err(line, e.to_string())),
205    }
206}
207
208fn parse_vector_constant(val_str: &str, line: usize) -> Result<ScConst, ParseError> {
209    let inner = val_str
210        .strip_prefix('[')
211        .and_then(|s| s.strip_suffix(']'))
212        .ok_or_else(|| make_err(line, "malformed vector constant"))?;
213    if inner.trim().is_empty() {
214        return Ok(ScConst::I64Vec(Vec::new()));
215    }
216    let is_float = inner.split(',').any(|part| part.trim().contains('.'));
217    if is_float {
218        let mut out = Vec::new();
219        for token in inner.split(',') {
220            out.push(
221                token
222                    .trim()
223                    .parse::<f64>()
224                    .map_err(|e| make_err(line, e.to_string()))?,
225            );
226        }
227        Ok(ScConst::F64Vec(out))
228    } else {
229        let mut out = Vec::new();
230        for token in inner.split(',') {
231            out.push(
232                token
233                    .trim()
234                    .parse::<i64>()
235                    .map_err(|e| make_err(line, e.to_string()))?,
236            );
237        }
238        Ok(ScConst::I64Vec(out))
239    }
240}
241
242// Op parsers
243
244fn parse_input(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
245    // %0 = sc.input "x_in" : rate
246    let parts: Vec<&str> = text.splitn(2, "= sc.input").collect();
247    if parts.len() != 2 {
248        return Err(make_err(line, "malformed sc.input"));
249    }
250    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
251    let rest = parts[1].trim();
252
253    // Extract name between quotes
254    let name_start = rest
255        .find('"')
256        .ok_or_else(|| make_err(line, "missing name"))?;
257    let name_end = rest[name_start + 1..]
258        .find('"')
259        .ok_or_else(|| make_err(line, "unterminated name"))?;
260    let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
261
262    // Extract type after ':'
263    let colon_pos = rest
264        .rfind(':')
265        .ok_or_else(|| make_err(line, "missing type"))?;
266    let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
267
268    graph.next_id = graph.next_id.max(id.0 + 1);
269    graph.push(ScOp::Input { id, name, ty });
270    Ok(())
271}
272
273fn parse_output(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
274    // sc.output "result" %5
275    let rest = text.strip_prefix("sc.output").unwrap_or(text).trim();
276    let name_start = rest
277        .find('"')
278        .ok_or_else(|| make_err(line, "missing name"))?;
279    let name_end = rest[name_start + 1..]
280        .find('"')
281        .ok_or_else(|| make_err(line, "unterminated name"))?;
282    let name = rest[name_start + 1..name_start + 1 + name_end].to_string();
283
284    let after_name = rest[name_start + 1 + name_end + 1..].trim();
285    let source = parse_value_id(after_name).map_err(|e| make_err(line, e))?;
286
287    let id = ValueId(graph.next_id);
288    graph.next_id += 1;
289    graph.push(ScOp::Output { id, name, source });
290    Ok(())
291}
292
293fn parse_constant(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
294    let parts: Vec<&str> = text.splitn(2, "= sc.constant").collect();
295    if parts.len() != 2 {
296        return Err(make_err(line, "malformed sc.constant"));
297    }
298    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
299    let rest = parts[1].trim();
300
301    let colon_pos = rest
302        .rfind(':')
303        .ok_or_else(|| make_err(line, "missing type"))?;
304    let val_str = rest[..colon_pos].trim();
305    let ty = parse_type(&rest[colon_pos + 1..]).map_err(|e| make_err(line, e))?;
306
307    let value = if val_str.starts_with('[') {
308        parse_vector_constant(val_str, line)?
309    } else {
310        parse_scalar_constant(val_str, &ty, line)?
311    };
312
313    graph.next_id = graph.next_id.max(id.0 + 1);
314    graph.push(ScOp::Constant { id, value, ty });
315    Ok(())
316}
317
318fn parse_encode(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
319    let parts: Vec<&str> = text.splitn(2, "= sc.encode").collect();
320    if parts.len() != 2 {
321        return Err(make_err(line, "malformed sc.encode"));
322    }
323    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
324    let rest = parts[1].trim();
325
326    // First token after "= sc.encode " is the prob operand.
327    let tokens: Vec<&str> = rest.split_whitespace().collect();
328    let prob = parse_value_id(
329        tokens
330            .first()
331            .ok_or_else(|| make_err(line, "missing prob"))?,
332    )
333    .map_err(|e| make_err(line, e))?;
334
335    let length_str = extract_kv(rest, "length").ok_or_else(|| make_err(line, "missing length"))?;
336    let length = length_str
337        .parse::<usize>()
338        .map_err(|e| make_err(line, e.to_string()))?;
339
340    let seed_str = extract_kv(rest, "seed").ok_or_else(|| make_err(line, "missing seed"))?;
341    let seed = if seed_str.starts_with("0x") || seed_str.starts_with("0X") {
342        u16::from_str_radix(&seed_str[2..], 16).map_err(|e| make_err(line, e.to_string()))?
343    } else {
344        seed_str
345            .parse::<u16>()
346            .map_err(|e| make_err(line, e.to_string()))?
347    };
348
349    graph.next_id = graph.next_id.max(id.0 + 1);
350    graph.push(ScOp::Encode {
351        id,
352        prob,
353        length,
354        seed,
355    });
356    Ok(())
357}
358
359fn parse_and(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
360    let parts: Vec<&str> = text.splitn(2, "= sc.and").collect();
361    if parts.len() != 2 {
362        return Err(make_err(line, "malformed sc.and"));
363    }
364    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
365    let rest = parts[1].trim();
366    let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
367    if operands.len() < 2 {
368        return Err(make_err(line, "sc.and needs 2 operands"));
369    }
370    let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
371    let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
372
373    graph.next_id = graph.next_id.max(id.0 + 1);
374    graph.push(ScOp::BitwiseAnd { id, lhs, rhs });
375    Ok(())
376}
377
378fn parse_popcount(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
379    let parts: Vec<&str> = text.splitn(2, "= sc.popcount").collect();
380    if parts.len() != 2 {
381        return Err(make_err(line, "malformed sc.popcount"));
382    }
383    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
384    let rest = parts[1].trim();
385    let input_str = rest.split(':').next().unwrap_or("").trim();
386    let input = parse_value_id(input_str).map_err(|e| make_err(line, e))?;
387
388    graph.next_id = graph.next_id.max(id.0 + 1);
389    graph.push(ScOp::Popcount { id, input });
390    Ok(())
391}
392
393fn parse_dense_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
394    let parts: Vec<&str> = text.splitn(2, "= sc.dense_forward").collect();
395    if parts.len() != 2 {
396        return Err(make_err(line, "malformed sc.dense_forward"));
397    }
398    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
399    let rest = parts[1].trim();
400
401    let tokens: Vec<&str> = rest.split_whitespace().collect();
402    let inputs = parse_value_id(
403        tokens
404            .first()
405            .ok_or_else(|| make_err(line, "missing inputs"))?,
406    )
407    .map_err(|e| make_err(line, e))?;
408
409    let weights_str =
410        extract_kv(rest, "weights").ok_or_else(|| make_err(line, "missing weights"))?;
411    let weights = parse_value_id(&weights_str).map_err(|e| make_err(line, e))?;
412
413    let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
414    let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
415
416    let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
417    let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
418
419    let ni = extract_kv(rest, "ni")
420        .and_then(|s| s.parse::<usize>().ok())
421        .unwrap_or(3);
422    let nn = extract_kv(rest, "nn")
423        .and_then(|s| s.parse::<usize>().ok())
424        .unwrap_or(7);
425    let len = extract_kv(rest, "len")
426        .and_then(|s| s.parse::<usize>().ok())
427        .unwrap_or(1024);
428
429    let params = DenseParams {
430        n_inputs: ni,
431        n_neurons: nn,
432        stream_length: len,
433        ..DenseParams::default()
434    };
435
436    graph.next_id = graph.next_id.max(id.0 + 1);
437    graph.push(ScOp::DenseForward {
438        id,
439        inputs,
440        weights,
441        leak,
442        gain,
443        params,
444    });
445    Ok(())
446}
447
448fn parse_lif_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
449    let parts: Vec<&str> = text.splitn(2, "= sc.lif_step").collect();
450    if parts.len() != 2 {
451        return Err(make_err(line, "malformed sc.lif_step"));
452    }
453    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
454    let rest = parts[1].trim();
455
456    let tokens: Vec<&str> = rest.split_whitespace().collect();
457    let current = parse_value_id(
458        tokens
459            .first()
460            .ok_or_else(|| make_err(line, "missing current"))?,
461    )
462    .map_err(|e| make_err(line, e))?;
463
464    let leak_str = extract_kv(rest, "leak").ok_or_else(|| make_err(line, "missing leak"))?;
465    let leak = parse_value_id(&leak_str).map_err(|e| make_err(line, e))?;
466
467    let gain_str = extract_kv(rest, "gain").ok_or_else(|| make_err(line, "missing gain"))?;
468    let gain = parse_value_id(&gain_str).map_err(|e| make_err(line, e))?;
469
470    let noise_str = extract_kv(rest, "noise").ok_or_else(|| make_err(line, "missing noise"))?;
471    let noise = parse_value_id(&noise_str).map_err(|e| make_err(line, e))?;
472
473    let dw = extract_kv(rest, "dw")
474        .and_then(|s| s.parse::<u32>().ok())
475        .unwrap_or(16);
476    let frac = extract_kv(rest, "frac")
477        .and_then(|s| s.parse::<u32>().ok())
478        .unwrap_or(8);
479    let vt = extract_kv(rest, "vt")
480        .and_then(|s| s.parse::<i64>().ok())
481        .unwrap_or(256);
482    let rp = extract_kv(rest, "rp")
483        .and_then(|s| s.parse::<u32>().ok())
484        .unwrap_or(2);
485
486    let params = LifParams {
487        data_width: dw,
488        fraction: frac,
489        v_threshold: vt,
490        refractory_period: rp,
491        ..LifParams::default()
492    };
493
494    graph.next_id = graph.next_id.max(id.0 + 1);
495    graph.push(ScOp::LifStep {
496        id,
497        current,
498        leak,
499        gain,
500        noise,
501        params,
502    });
503    Ok(())
504}
505
506fn parse_scale(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
507    let parts: Vec<&str> = text.splitn(2, "= sc.scale").collect();
508    if parts.len() != 2 {
509        return Err(make_err(line, "malformed sc.scale"));
510    }
511    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
512    let rest = parts[1].trim();
513
514    let tokens: Vec<&str> = rest.split_whitespace().collect();
515    let input = parse_value_id(
516        tokens
517            .first()
518            .ok_or_else(|| make_err(line, "missing input"))?,
519    )
520    .map_err(|e| make_err(line, e))?;
521
522    let factor_str = extract_kv(rest, "factor").ok_or_else(|| make_err(line, "missing factor"))?;
523    let factor = factor_str
524        .parse::<f64>()
525        .map_err(|e| make_err(line, e.to_string()))?;
526
527    graph.next_id = graph.next_id.max(id.0 + 1);
528    graph.push(ScOp::Scale { id, input, factor });
529    Ok(())
530}
531
532fn parse_offset(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
533    let parts: Vec<&str> = text.splitn(2, "= sc.offset").collect();
534    if parts.len() != 2 {
535        return Err(make_err(line, "malformed sc.offset"));
536    }
537    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
538    let rest = parts[1].trim();
539
540    let tokens: Vec<&str> = rest.split_whitespace().collect();
541    let input = parse_value_id(
542        tokens
543            .first()
544            .ok_or_else(|| make_err(line, "missing input"))?,
545    )
546    .map_err(|e| make_err(line, e))?;
547
548    let offset_str = extract_kv(rest, "offset").ok_or_else(|| make_err(line, "missing offset"))?;
549    let offset = offset_str
550        .parse::<f64>()
551        .map_err(|e| make_err(line, e.to_string()))?;
552
553    graph.next_id = graph.next_id.max(id.0 + 1);
554    graph.push(ScOp::Offset { id, input, offset });
555    Ok(())
556}
557
558fn parse_xor(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
559    let parts: Vec<&str> = text.splitn(2, "= sc.xor").collect();
560    if parts.len() != 2 {
561        return Err(make_err(line, "malformed sc.xor"));
562    }
563    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
564    let rest = parts[1].trim();
565    let operands: Vec<&str> = rest.split(':').next().unwrap_or("").split(',').collect();
566    if operands.len() < 2 {
567        return Err(make_err(line, "sc.xor needs 2 operands"));
568    }
569    let lhs = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
570    let rhs = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
571
572    graph.next_id = graph.next_id.max(id.0 + 1);
573    graph.push(ScOp::BitwiseXor { id, lhs, rhs });
574    Ok(())
575}
576
577fn parse_reduce(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
578    let parts: Vec<&str> = text.splitn(2, "= sc.reduce").collect();
579    if parts.len() != 2 {
580        return Err(make_err(line, "malformed sc.reduce"));
581    }
582    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
583    let rest = parts[1].trim();
584
585    let tokens: Vec<&str> = rest.split_whitespace().collect();
586    let input = parse_value_id(
587        tokens
588            .first()
589            .ok_or_else(|| make_err(line, "missing input"))?,
590    )
591    .map_err(|e| make_err(line, e))?;
592
593    let mode_str = extract_kv(rest, "mode").unwrap_or_else(|| "sum".to_string());
594    let mode = match mode_str.as_str() {
595        "max" => ReduceMode::Max,
596        _ => ReduceMode::Sum,
597    };
598
599    graph.next_id = graph.next_id.max(id.0 + 1);
600    graph.push(ScOp::Reduce { id, input, mode });
601    Ok(())
602}
603
604fn parse_graph_forward(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
605    let parts: Vec<&str> = text.splitn(2, "= sc.graph_forward").collect();
606    if parts.len() != 2 {
607        return Err(make_err(line, "malformed sc.graph_forward"));
608    }
609    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
610    let rest = parts[1].trim();
611
612    let tokens: Vec<&str> = rest.split_whitespace().collect();
613    let features = parse_value_id(
614        tokens
615            .first()
616            .ok_or_else(|| make_err(line, "missing features"))?,
617    )
618    .map_err(|e| make_err(line, e))?;
619
620    let adj_str = extract_kv(rest, "adj").ok_or_else(|| make_err(line, "missing adj"))?;
621    let adjacency = parse_value_id(&adj_str).map_err(|e| make_err(line, e))?;
622
623    let n_nodes = extract_kv(rest, "nodes")
624        .and_then(|s| s.parse::<usize>().ok())
625        .unwrap_or(16);
626    let n_features = extract_kv(rest, "features")
627        .and_then(|s| s.parse::<usize>().ok())
628        .unwrap_or(4);
629
630    graph.next_id = graph.next_id.max(id.0 + 1);
631    graph.push(ScOp::GraphForward {
632        id,
633        features,
634        adjacency,
635        n_nodes,
636        n_features,
637    });
638    Ok(())
639}
640
641fn parse_softmax_attention(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
642    let parts: Vec<&str> = text.splitn(2, "= sc.softmax_attention").collect();
643    if parts.len() != 2 {
644        return Err(make_err(line, "malformed sc.softmax_attention"));
645    }
646    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
647    let rest = parts[1].trim();
648
649    let before_colon = rest.split(':').next().unwrap_or("");
650    let operands: Vec<&str> = before_colon.split(',').collect();
651    if operands.len() < 3 {
652        return Err(make_err(line, "sc.softmax_attention needs q, k, v"));
653    }
654    let q = parse_value_id(operands[0]).map_err(|e| make_err(line, e))?;
655    let k = parse_value_id(operands[1]).map_err(|e| make_err(line, e))?;
656
657    // v operand may include dim_k= key-value pair
658    let v_token = operands[2].split_whitespace().next().unwrap_or("");
659    let v = parse_value_id(v_token).map_err(|e| make_err(line, e))?;
660
661    let dim_k = extract_kv(rest, "dim_k")
662        .and_then(|s| s.parse::<usize>().ok())
663        .unwrap_or(64);
664
665    graph.next_id = graph.next_id.max(id.0 + 1);
666    graph.push(ScOp::SoftmaxAttention { id, q, k, v, dim_k });
667    Ok(())
668}
669
670fn parse_kuramoto_step(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
671    let parts: Vec<&str> = text.splitn(2, "= sc.kuramoto_step").collect();
672    if parts.len() != 2 {
673        return Err(make_err(line, "malformed sc.kuramoto_step"));
674    }
675    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
676    let rest = parts[1].trim();
677
678    let tokens: Vec<&str> = rest.split_whitespace().collect();
679    let phases = parse_value_id(
680        tokens
681            .first()
682            .ok_or_else(|| make_err(line, "missing phases"))?,
683    )
684    .map_err(|e| make_err(line, e))?;
685
686    let omega_str = extract_kv(rest, "omega").ok_or_else(|| make_err(line, "missing omega"))?;
687    let omega = parse_value_id(&omega_str).map_err(|e| make_err(line, e))?;
688
689    let k_str = extract_kv(rest, "K").ok_or_else(|| make_err(line, "missing K"))?;
690    let coupling = parse_value_id(&k_str).map_err(|e| make_err(line, e))?;
691
692    let dt = extract_kv(rest, "dt")
693        .and_then(|s| s.parse::<f64>().ok())
694        .unwrap_or(0.01);
695
696    graph.next_id = graph.next_id.max(id.0 + 1);
697    graph.push(ScOp::KuramotoStep {
698        id,
699        phases,
700        omega,
701        coupling,
702        dt,
703    });
704    Ok(())
705}
706
707fn parse_div_const(text: &str, graph: &mut ScGraph, line: usize) -> Result<(), ParseError> {
708    let parts: Vec<&str> = text.splitn(2, "= sc.div_const").collect();
709    if parts.len() != 2 {
710        return Err(make_err(line, "malformed sc.div_const"));
711    }
712    let id = parse_value_id(parts[0]).map_err(|e| make_err(line, e))?;
713    let rest = parts[1].trim();
714
715    let tokens: Vec<&str> = rest.split_whitespace().collect();
716    let input = parse_value_id(
717        tokens
718            .first()
719            .ok_or_else(|| make_err(line, "missing input"))?,
720    )
721    .map_err(|e| make_err(line, e))?;
722
723    let divisor_str =
724        extract_kv(rest, "divisor").ok_or_else(|| make_err(line, "missing divisor"))?;
725    let divisor = divisor_str
726        .parse::<u64>()
727        .map_err(|e| make_err(line, e.to_string()))?;
728
729    graph.next_id = graph.next_id.max(id.0 + 1);
730    graph.push(ScOp::DivConst { id, input, divisor });
731    Ok(())
732}