// Shared parser: program_repr (typed tree, opaque IDs) -> render model. // // Grammar (engine_v2 full DSL): // Vector := Reduce(Matrix, agg) // | Combine(Vector, Vector, op) // | Split(Vector, predicate) // | FitApply(Vector, target) // Matrix := M // | Select(Matrix, FeatureSet) // | Search(Matrix, k) // Scalar := Associate(Vector, target, kind) // | Effect(Vector, target, kind) // FeatureSet := [id1,id2,...] // // We also accept the legacy engine_v1 string shape // "Fit(Reduce(Select(M, [N ids]), mean), ... -> target)" // by surfacing a "Fit" wrapper node — useful when the page is showing // an older persisted run. export type ReprNode = | { kind: "M" } | { kind: "Select"; matrix: ReprNode; features: string[] } | { kind: "Search"; matrix: ReprNode; k: number } | { kind: "Reduce"; matrix: ReprNode; agg: string } | { kind: "Combine"; left: ReprNode; right: ReprNode; op: string } | { kind: "Split"; inner: ReprNode; predicate: string } | { kind: "FitApply"; inner: ReprNode; target: string } | { kind: "Associate"; inner: ReprNode; target: string; assocKind: string } | { kind: "Effect"; inner: ReprNode; target: string; assocKind: string } | { kind: "Fit"; children: ReprNode[]; output: string } | { kind: "Unknown"; text: string }; class _Parser { s: string; i: number; constructor(s: string) { this.s = s; this.i = 0; } peek(): string { return this.s[this.i] ?? ""; } eof(): boolean { return this.i >= this.s.length; } skip() { while (!this.eof() && /\s/.test(this.peek())) this.i++; } expect(ch: string) { this.skip(); if (this.peek() !== ch) { throw new Error( `expected '${ch}' at offset ${this.i}, got '${this.peek()}' in ${this.s}`, ); } this.i++; } ident(): string { this.skip(); const start = this.i; while (!this.eof() && /[A-Za-z0-9_]/.test(this.peek())) this.i++; return this.s.slice(start, this.i); } parseFeatureSet(): string[] { this.expect("["); const out: string[] = []; this.skip(); if (this.peek() === "]") { this.i++; return out; } while (true) { this.skip(); const start = this.i; while (!this.eof() && /[^,\]\s]/.test(this.peek())) this.i++; out.push(this.s.slice(start, this.i).trim()); this.skip(); if (this.peek() === ",") { this.i++; continue; } if (this.peek() === "]") { this.i++; return out; } throw new Error(`bad FeatureSet at offset ${this.i}`); } } parseExpr(): ReprNode { this.skip(); const head = this.ident(); if (head === "M") return { kind: "M" }; if (head === "Select") { this.expect("("); const matrix = this.parseExpr(); this.expect(","); const features = this.parseFeatureSet(); this.expect(")"); return { kind: "Select", matrix, features }; } if (head === "Reduce") { this.expect("("); const matrix = this.parseExpr(); this.expect(","); const agg = this.ident(); this.expect(")"); return { kind: "Reduce", matrix, agg }; } if (head === "Combine") { this.expect("("); const left = this.parseExpr(); this.expect(","); const right = this.parseExpr(); this.expect(","); const op = this.ident(); this.expect(")"); return { kind: "Combine", left, right, op }; } if (head === "Search") { this.expect("("); const matrix = this.parseExpr(); this.expect(","); this.skip(); const kStart = this.i; while (!this.eof() && /[0-9]/.test(this.peek())) this.i++; const k = Number.parseInt(this.s.slice(kStart, this.i), 10) || 1; this.expect(")"); return { kind: "Search", matrix, k }; } if (head === "Split") { this.expect("("); const inner = this.parseExpr(); this.expect(","); const predicate = this.ident(); this.expect(")"); return { kind: "Split", inner, predicate }; } if (head === "FitApply") { this.expect("("); const inner = this.parseExpr(); this.expect(","); const target = this.ident(); this.expect(")"); return { kind: "FitApply", inner, target }; } if (head === "Associate") { this.expect("("); const inner = this.parseExpr(); this.expect(","); const target = this.ident(); this.expect(","); const assocKind = this.ident(); this.expect(")"); return { kind: "Associate", inner, target, assocKind }; } if (head === "Effect") { this.expect("("); const inner = this.parseExpr(); this.expect(","); const target = this.ident(); this.expect(","); const assocKind = this.ident(); this.expect(")"); return { kind: "Effect", inner, target, assocKind }; } if (head === "Fit") { return this.parseFitTail(); } throw new Error(`unknown head '${head}' at offset ${this.i}`); } /** Tolerant parser for the legacy v1 shape: * Fit(Reduce(Select(M, [3 ids]), mean), ... -> target) * It doesn't always serialise gene IDs (sometimes it's "[N ids]"). We * pull out the comma-separated children and an "-> output" tail; we * keep raw text for unknown bits. */ parseFitTail(): ReprNode { this.expect("("); const children: ReprNode[] = []; let output = "target"; while (true) { this.skip(); // Detect "-> output" marker. if (this.s.startsWith("->", this.i)) { this.i += 2; this.skip(); const start = this.i; let depth = 0; while (!this.eof()) { const ch = this.peek(); if (ch === "(") depth++; else if (ch === ")") { if (depth === 0) break; depth--; } this.i++; } output = this.s.slice(start, this.i).trim(); break; } // Try to parse a known child. If it fails, skip until comma/paren. const safeStart = this.i; try { const child = this.parseExpr(); children.push(child); } catch { this.i = safeStart; let depth = 0; const start = this.i; while (!this.eof()) { const ch = this.peek(); if (ch === "(") depth++; else if (ch === ")") { if (depth === 0) break; depth--; } else if (ch === "," && depth === 0) break; this.i++; } children.push({ kind: "Unknown", text: this.s.slice(start, this.i).trim() }); } this.skip(); if (this.peek() === ",") { this.i++; continue; } break; } this.expect(")"); return { kind: "Fit", children, output }; } } export function parseProgramRepr(repr: string): ReprNode { const p = new _Parser(repr.trim()); p.skip(); return p.parseExpr(); } export function safeParseProgramRepr(repr: string): { ok: boolean; tree: ReprNode | null; error?: string; } { try { return { ok: true, tree: parseProgramRepr(repr) }; } catch (e) { return { ok: false, tree: null, error: e instanceof Error ? e.message : String(e), }; } } // Walk a tree and collect every FeatureSet leaf's IDs (union, ordered). export function geneIds(tree: ReprNode): string[] { const out: string[] = []; const seen = new Set(); function visit(n: ReprNode) { switch (n.kind) { case "Select": for (const id of n.features) { if (!seen.has(id)) { seen.add(id); out.push(id); } } visit(n.matrix); return; case "Search": case "Reduce": visit(n.matrix); return; case "Combine": visit(n.left); visit(n.right); return; case "Split": case "FitApply": case "Associate": case "Effect": visit(n.inner); return; case "Fit": for (const c of n.children) visit(c); return; case "M": case "Unknown": return; } } visit(tree); return out; }