Skip to main content

sc_neurocore_engine/ir/
parser.rs

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