"""NL→DAG Parser — extract causal DAGs from free-form natural language text. Four LLM backends: 1. Claude API (ANTHROPIC_API_KEY) — best accuracy 2. Ollama local (if running) — free/offline 3. Local Qwen LoRA (models/nl_dag_adapter) — fine-tuned, no API, offline 4. Regex fallback — no LLM, pattern matching only Bridge function converts parsed DAGs to CausalEng input tensors. """ import json import os import re from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple # ============================================================ # Data Classes # ============================================================ @dataclass class CausalVariable: """A variable in a causal DAG.""" name: str index: int aliases: List[str] = field(default_factory=list) @dataclass class CausalDAG: """A causal DAG extracted from text.""" variables: List[CausalVariable] edges: List[Tuple[int, int]] # (src_idx, dst_idx) name_to_idx: Dict[str, int] source_text: str # Optional enrichments from LLM (e.g. qwen2.5:14b or Claude) bidirected_names: List[Tuple[str, str]] = field(default_factory=list) # hidden confounders params: Dict[str, any] = field(default_factory=dict) # CPT tables if extracted suggested_query: Optional[Dict] = None # {type, treatment, outcome} if LLM found a question @property def num_nodes(self): return len(self.variables) @property def num_edges(self): return len(self.edges) def variable_names(self): return [v.name for v in self.variables] @dataclass class CausalQuery: """A causal query parsed against a DAG.""" query_type: str # "causal", "independence", "effect" x_name: str y_name: str z_names: List[str] = field(default_factory=list) x_idx: int = -1 y_idx: int = -1 z_indices: List[int] = field(default_factory=list) # ============================================================ # Regex Causal Patterns (no LLM needed) # ============================================================ # Forward patterns: "X causes Y", "X leads to Y", etc. _FORWARD_PATTERNS = [ re.compile(r"([A-Za-z][\w\s'-]*?)\s+causes\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+leads\s+to\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+affects\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+influences\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+has a direct effect on\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+directly affects\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+results in\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+increases\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+decreases\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+produces\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), ] # Reverse patterns: "Y depends on X" → edge X→Y _REVERSE_PATTERNS = [ re.compile(r"([A-Za-z][\w\s'-]*?)\s+depends on\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+is caused by\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+is affected by\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+is influenced by\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), re.compile(r"([A-Za-z][\w\s'-]*?)\s+is determined by\s+([A-Za-z][\w\s'-]*?)(?:\.|,|;|$)", re.IGNORECASE), ] # Query patterns _QUERY_CAUSAL = re.compile( r"does\s+([A-Za-z][\w\s'-]*?)\s+cause\s+([A-Za-z][\w\s'-]*?)\s*\?", re.IGNORECASE, ) _QUERY_EFFECT = re.compile( r"what\s+is\s+the\s+effect\s+of\s+([A-Za-z][\w\s'-]*?)\s+on\s+([A-Za-z][\w\s'-]*?)\s*\?", re.IGNORECASE, ) _QUERY_INDEPENDENCE = re.compile( r"is\s+([A-Za-z][\w\s'-]*?)\s+independent\s+of\s+([A-Za-z][\w\s'-]*?)\s+given\s+([A-Za-z][\w\s'-]*?)\s*\?", re.IGNORECASE, ) _QUERY_INDEPENDENCE_NO_Z = re.compile( r"is\s+([A-Za-z][\w\s'-]*?)\s+independent\s+of\s+([A-Za-z][\w\s'-]*?)\s*\?", re.IGNORECASE, ) # ============================================================ # LLM Prompt # ============================================================ _LOCAL_SYSTEM_MSG = ( "You are a causal graph extractor. Convert natural language descriptions of " "causal relationships into structured JSON with nodes, edges, and a query. " "Respond only with valid JSON." ) _OLLAMA_SYSTEM_MSG = ( "You are a causal inference expert. Extract structured causal models from " "natural language. Always respond with valid JSON only — no explanation, " "no markdown fences, no extra text." ) # Used for claude backend (plain prompt string) _LLM_PROMPT = """Extract the causal variables and directed edges from this text. Return ONLY valid JSON in this exact format: {{"variables": ["var1", "var2", ...], "edges": [["source", "target"], ...]}} Rules: - Variables are nouns/concepts mentioned as causes or effects - Edges go from cause to effect (source → target) - Use exact variable names from the text - Do not add edges that aren't stated or implied Text: {text}""" # Richer prompt for Qwen2.5-14B — extracts full SCM including CPTs and confounders _OLLAMA_DAG_PROMPT = """Extract a complete causal model from the text below. Return ONLY a JSON object with these fields: - "variables": list of variable names (strings) - "edges": list of [source, target] directed edges (cause → effect) - "bidirected": list of [A, B] pairs where a hidden common cause exists (omit if none) - "params": object with conditional probability tables if numeric probabilities are stated, e.g. {{"p(X)": 0.3, "p(Y | X)": [0.2, 0.8]}} — omit if no probabilities mentioned - "query": object with {{"type": "ate"|"ett"|"nde"|"marginal"|"conditional"|"do", "treatment": "var", "outcome": "var"}} if the text contains a causal question — omit otherwise Rules: - Use short, clean variable names (lowercase, underscores for spaces) - Edges go from cause to effect only - Only include bidirected edges when the text explicitly mentions confounding or a hidden common cause - Only include params when specific probability values appear in the text Text: {text}""" # Used for local backend — matches training format exactly # Training inputs always had a question appended; output uses nodes/from/to _LOCAL_DAG_QUERY = "Identify all causal relationships and extract the complete causal graph." _QUERY_LLM_PROMPT = """Parse this causal query against the given variables. Return ONLY valid JSON: {{"query_type": "causal"|"independence"|"effect", "x": "variable_name", "y": "variable_name", "z": ["conditioning_var1", ...]}} Variables in the DAG: {variables} Query: {query}""" _OLLAMA_QUERY_PROMPT = """Identify the causal query type and variables from the question below. Return ONLY a JSON object: {{"query_type": "causal"|"independence"|"effect"|"ate"|"ett"|"nde"|"marginal"|"conditional"|"do", "x": "treatment_variable", "y": "outcome_variable", "z": ["conditioning_var1", ...]}} Available variables: {variables} Question: {query}""" # ============================================================ # LLM Backends # ============================================================ def _call_claude(prompt: str, model: str = "claude-sonnet-4-20250514") -> str: """Call Claude API. Returns raw text response.""" import httpx api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: raise RuntimeError("ANTHROPIC_API_KEY not set") resp = httpx.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": model, "max_tokens": 2048, "messages": [{"role": "user", "content": prompt}], }, timeout=60.0, ) resp.raise_for_status() return resp.json()["content"][0]["text"] _LOCAL_MODEL = None # lazy-loaded singleton _LOCAL_TOKENIZER = None def _call_local_qwen(prompt: str, adapter_path: Optional[str] = None) -> str: """Run inference with the fine-tuned Qwen2.5-1.5B LoRA adapter. Loads model once and caches it. No API key required. adapter_path defaults to models/nl_dag_adapter/ relative to this file. """ global _LOCAL_MODEL, _LOCAL_TOKENIZER if _LOCAL_MODEL is None: import torch from transformers import AutoTokenizer, AutoModelForCausalLM from peft import PeftModel if adapter_path is None: here = os.path.dirname(os.path.abspath(__file__)) adapter_path = os.path.join(here, "..", "models", "nl_dag_adapter") adapter_path = os.path.normpath(adapter_path) base_id = "Qwen/Qwen2.5-1.5B-Instruct" device = "mps" if torch.backends.mps.is_available() else ( "cuda" if torch.cuda.is_available() else "cpu" ) # Load tokenizer from base model (adapter tokenizer_config may have # list-format extra_special_tokens incompatible with older transformers) _LOCAL_TOKENIZER = AutoTokenizer.from_pretrained(base_id) base = AutoModelForCausalLM.from_pretrained( base_id, torch_dtype=torch.float16 if device != "cpu" else torch.float32, device_map=device, ) _LOCAL_MODEL = PeftModel.from_pretrained(base, adapter_path) _LOCAL_MODEL.eval() messages = [ {"role": "system", "content": _LOCAL_SYSTEM_MSG}, {"role": "user", "content": prompt}, ] text = _LOCAL_TOKENIZER.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) inputs = _LOCAL_TOKENIZER(text, return_tensors="pt").to(_LOCAL_MODEL.device) import torch with torch.no_grad(): outputs = _LOCAL_MODEL.generate( **inputs, max_new_tokens=512, temperature=0.1, do_sample=False, pad_token_id=_LOCAL_TOKENIZER.eos_token_id, ) generated = outputs[0][inputs["input_ids"].shape[1]:] return _LOCAL_TOKENIZER.decode(generated, skip_special_tokens=True) def _call_ollama( prompt: str, model: str = "qwen2.5:14b", system: str = "", temperature: float = 0.0, ) -> str: """Call local Ollama via the /api/chat endpoint (supports system messages). Uses streaming to handle large models without timeout issues. Falls back to /api/generate if chat endpoint returns 404. """ import httpx messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) chunks = [] try: with httpx.stream( "POST", "http://localhost:11434/api/chat", json={ "model": model, "messages": messages, "stream": True, "options": {"temperature": temperature, "num_predict": 1024}, }, timeout=httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0), ) as response: response.raise_for_status() for line in response.iter_lines(): if line: try: chunk = json.loads(line) content = chunk.get("message", {}).get("content", "") chunks.append(content) if chunk.get("done"): break except json.JSONDecodeError: continue except httpx.HTTPStatusError: # Fallback: older Ollama versions may not support /api/chat full_prompt = (system + "\n\n" + prompt).strip() if system else prompt with httpx.stream( "POST", "http://localhost:11434/api/generate", json={"model": model, "prompt": full_prompt, "stream": True, "options": {"temperature": temperature, "num_predict": 1024}}, timeout=httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0), ) as response: response.raise_for_status() for line in response.iter_lines(): if line: try: chunk = json.loads(line) chunks.append(chunk.get("response", "")) if chunk.get("done"): break except json.JSONDecodeError: continue return "".join(chunks) def _parse_json_response(text: str) -> dict: """Extract JSON object from LLM response text.""" text = text.strip() # Try direct parse try: result = json.loads(text) if isinstance(result, dict): return result except json.JSONDecodeError: pass # Find JSON object in response match = re.search(r'\{[\s\S]*\}', text) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass return {} # ============================================================ # Core Parser # ============================================================ class NLDAGParser: """Parse natural language text into causal DAGs. Supports three backends: - "claude": Claude API (best accuracy) - "ollama": Local Ollama (free/offline) - "regex": Pattern matching fallback (no LLM) """ def __init__(self, backend: str = "regex", model: Optional[str] = None, adapter_path: Optional[str] = None): """ Args: backend: "claude", "ollama", "local", or "regex" model: override model name for claude/ollama backends adapter_path: path to LoRA adapter dir (local backend only; defaults to models/nl_dag_adapter/) """ self.backend = backend self.model = model self.adapter_path = adapter_path def parse(self, text: str) -> CausalDAG: """Extract causal DAG from natural language text. Returns: CausalDAG with variables, edges, and name mappings """ if self.backend == "regex": return self._parse_regex(text) elif self.backend == "hybrid": # Local model first; fall back to regex if it returns no edges dag = self._parse_llm_with_backend("local", text) if dag.num_edges == 0: dag = self._parse_regex(text) return dag elif self.backend in ("claude", "ollama", "local"): return self._parse_llm(text) else: raise ValueError(f"Unknown backend: {self.backend}") def parse_query(self, query_text: str, dag: CausalDAG) -> CausalQuery: """Parse a causal query against a known DAG. Args: query_text: e.g. "Does A cause C?", "Is A independent of C given B?" dag: the CausalDAG to resolve variable names against Returns: CausalQuery with resolved indices """ if self.backend in ("claude", "ollama", "local", "hybrid"): return self._parse_query_llm(query_text, dag) return self._parse_query_regex(query_text, dag) def parse_with_query(self, text: str, query_text: str) -> Tuple[CausalDAG, CausalQuery]: """Parse both DAG and query from text. Returns: (CausalDAG, CausalQuery) """ dag = self.parse(text) query = self.parse_query(query_text, dag) return dag, query # ── Regex parsing ────────────────────────────────────────── def _parse_regex(self, text: str) -> CausalDAG: """Extract DAG using regex patterns.""" raw_edges = [] # Forward patterns: group(1) → group(2) for pattern in _FORWARD_PATTERNS: for match in pattern.finditer(text): src = match.group(1).strip() # Handle multi-target: "X causes B and C" targets_str = match.group(2).strip() targets = re.split(r'\s+and\s+', targets_str) for t in targets: t = t.strip().rstrip('.,;') if t: raw_edges.append((src, t)) # Reverse patterns: group(1) ← group(2), i.e. edge from group(2) to group(1) for pattern in _REVERSE_PATTERNS: for match in pattern.finditer(text): dst = match.group(1).strip() src = match.group(2).strip() raw_edges.append((src.rstrip('.,;'), dst.rstrip('.,;'))) return self._build_dag(raw_edges, text) def _build_dag(self, raw_edges: List[Tuple[str, str]], source_text: str) -> CausalDAG: """Convert raw name-based edges into a CausalDAG.""" # Normalize names name_map = {} # normalized -> canonical for src, dst in raw_edges: for name in (src, dst): norm = name.strip().lower() if norm not in name_map: name_map[norm] = name.strip() # Assign indices canonical_names = list(name_map.values()) # Deduplicate while preserving order seen = set() unique_names = [] for n in canonical_names: key = n.lower() if key not in seen: seen.add(key) unique_names.append(n) name_to_idx = {n.lower(): i for i, n in enumerate(unique_names)} variables = [ CausalVariable(name=n, index=i) for i, n in enumerate(unique_names) ] # Build indexed edges (dedup) edges = [] edge_set = set() for src, dst in raw_edges: si = name_to_idx.get(src.strip().lower()) di = name_to_idx.get(dst.strip().lower()) if si is not None and di is not None and si != di: pair = (si, di) if pair not in edge_set: edge_set.add(pair) edges.append(pair) return CausalDAG( variables=variables, edges=edges, name_to_idx=name_to_idx, source_text=source_text, ) def _parse_query_regex(self, query_text: str, dag: CausalDAG) -> CausalQuery: """Parse query using regex patterns.""" # Try independence with conditioning m = _QUERY_INDEPENDENCE.match(query_text) if m: x_name = m.group(1).strip() y_name = m.group(2).strip() z_str = m.group(3).strip() z_names = [z.strip() for z in re.split(r'\s+and\s+|,\s*', z_str)] return self._resolve_query("independence", x_name, y_name, z_names, dag) # Independence without conditioning m = _QUERY_INDEPENDENCE_NO_Z.match(query_text) if m: x_name = m.group(1).strip() y_name = m.group(2).strip() return self._resolve_query("independence", x_name, y_name, [], dag) # Causal query m = _QUERY_CAUSAL.match(query_text) if m: x_name = m.group(1).strip() y_name = m.group(2).strip() return self._resolve_query("causal", x_name, y_name, [], dag) # Effect query m = _QUERY_EFFECT.match(query_text) if m: x_name = m.group(1).strip() y_name = m.group(2).strip() return self._resolve_query("effect", x_name, y_name, [], dag) raise ValueError(f"Could not parse query: {query_text}") def _resolve_query(self, query_type: str, x_name: str, y_name: str, z_names: List[str], dag: CausalDAG) -> CausalQuery: """Resolve variable names against DAG using fuzzy matching.""" x_idx = self._resolve_variable(x_name, dag) y_idx = self._resolve_variable(y_name, dag) z_indices = [self._resolve_variable(z, dag) for z in z_names] return CausalQuery( query_type=query_type, x_name=x_name, y_name=y_name, z_names=z_names, x_idx=x_idx, y_idx=y_idx, z_indices=z_indices, ) def _resolve_variable(self, name: str, dag: CausalDAG) -> int: """Resolve a variable name against DAG, with fuzzy matching. Tries: exact match, case-insensitive, substring, first-letter. """ norm = name.strip().lower() # Exact match if norm in dag.name_to_idx: return dag.name_to_idx[norm] # Substring match (name is substring of variable or vice versa) for var_name, idx in dag.name_to_idx.items(): if norm in var_name or var_name in norm: return idx # First letter match (for single-letter references like "A", "B") if len(norm) == 1: for var_name, idx in dag.name_to_idx.items(): if var_name.startswith(norm): return idx raise ValueError(f"Cannot resolve variable '{name}' in DAG with variables: " f"{list(dag.name_to_idx.keys())}") # ── LLM parsing ────────────────────────────────────────── def _parse_llm_with_backend(self, backend: str, text: str) -> CausalDAG: """Run _parse_llm using an explicit backend name (used by hybrid).""" saved = self.backend self.backend = backend try: return self._parse_llm(text) finally: self.backend = saved def _parse_llm(self, text: str) -> CausalDAG: """Extract DAG using LLM backend.""" if self.backend == "local": # Match training format: prose + question, system message added in _call_local_qwen prompt = f"{text[:50000].strip()}\n{_LOCAL_DAG_QUERY}" raw = _call_local_qwen(prompt, adapter_path=self.adapter_path) elif self.backend == "claude": prompt = _LLM_PROMPT.format(text=text[:50000]) model = self.model or "claude-sonnet-4-6" raw = _call_claude(prompt, model=model) else: # Ollama — use richer prompt and system message for instruction models prompt = _OLLAMA_DAG_PROMPT.format(text=text[:50000]) model = self.model or "qwen2.5:14b" raw = _call_ollama(prompt, model=model, system=_OLLAMA_SYSTEM_MSG) data = _parse_json_response(raw) # Handle both "variables" (claude/ollama) and "nodes" (local training format) variables = data.get("variables") or data.get("nodes", []) raw_edges = data.get("edges", []) if not variables: # Fallback to regex return self._parse_regex(text) # Build from LLM output — handle both array ["src","dst"] and # dict {"source":"src","target":"dst"} edge formats name_pairs = [] for e in raw_edges: if isinstance(e, dict): src = e.get("from") or e.get("source") or e.get("src") dst = e.get("to") or e.get("target") or e.get("dst") if src and dst: name_pairs.append((src, dst)) elif hasattr(e, "__len__") and len(e) >= 2: name_pairs.append((e[0], e[1])) # Add any variables mentioned in edges but not in variables list var_set = set(v.lower() for v in variables) for src, dst in name_pairs: if src.lower() not in var_set: variables.append(src) var_set.add(src.lower()) if dst.lower() not in var_set: variables.append(dst) var_set.add(dst.lower()) dag = self._build_dag(name_pairs, text) # Capture enrichments from richer LLM responses (ollama/Claude) raw_bidir = data.get("bidirected", []) dag.bidirected_names = [ (e[0], e[1]) if isinstance(e, (list, tuple)) and len(e) >= 2 else (e.get("from", ""), e.get("to", "")) for e in raw_bidir ] dag.params = data.get("params", {}) dag.suggested_query = data.get("query") # {type, treatment, outcome} or None return dag def _parse_query_llm(self, query_text: str, dag: CausalDAG) -> CausalQuery: """Parse query using LLM backend.""" var_names = dag.variable_names() if self.backend == "claude": prompt = _QUERY_LLM_PROMPT.format(variables=", ".join(var_names), query=query_text) model = self.model or "claude-sonnet-4-6" raw = _call_claude(prompt, model=model) elif self.backend == "local": prompt = _QUERY_LLM_PROMPT.format(variables=", ".join(var_names), query=query_text) raw = _call_local_qwen(prompt, adapter_path=self.adapter_path) else: prompt = _OLLAMA_QUERY_PROMPT.format(variables=", ".join(var_names), query=query_text) model = self.model or "qwen2.5:14b" raw = _call_ollama(prompt, model=model, system=_OLLAMA_SYSTEM_MSG) data = _parse_json_response(raw) if not data: # Fallback to regex return self._parse_query_regex(query_text, dag) query_type = data.get("query_type", "causal") x_name = data.get("x", "") y_name = data.get("y", "") z_names = data.get("z", []) return self._resolve_query(query_type, x_name, y_name, z_names, dag) # ============================================================ # Bridge: DAG → CausalEng Input Tensors # ============================================================ def dag_to_causal_eng_input(dag: CausalDAG, query: CausalQuery, device=None) -> dict: """Convert parsed DAG + query into CausalEng input tensors. Uses CausalEng.build_node_features() to produce exact [N, 6] features. Returns: dict with keys: node_features, edge_index, query_x_idx, query_y_idx, z_indices """ import torch from models.causal_eng import CausalEng if device is None: device = torch.device("cpu") node_features = CausalEng.build_node_features( num_nodes=dag.num_nodes, edges=dag.edges, query_x=query.x_idx, query_y=query.y_idx, z_set=query.z_indices, device=device, ) # Edge index as [2, E] tensor if dag.edges: srcs, dsts = zip(*dag.edges) edge_index = torch.tensor([list(srcs), list(dsts)], dtype=torch.long, device=device) else: edge_index = torch.zeros(2, 0, dtype=torch.long, device=device) return { "node_features": node_features, "edge_index": edge_index, "query_x_idx": query.x_idx, "query_y_idx": query.y_idx, "z_indices": query.z_indices, } # ============================================================ # End-to-End Pipeline # ============================================================ def text_to_causal_query(text: str, query: str, backend: str = "regex", model: Optional[str] = None, adapter_path: Optional[str] = None, device=None) -> dict: """Full pipeline: text → DAG → tensors → model inference → result. Args: text: natural language describing causal relationships query: causal question (e.g. "Does A cause C?") backend: "claude", "ollama", "local", or "regex" model: optional model override (claude/ollama backends) adapter_path: path to LoRA adapter dir (local backend only) device: torch device Returns: dict with keys: dag, query, tensors, variables, edges (model inference is optional — returns tensors ready for CausalEng) """ parser = NLDAGParser(backend=backend, model=model, adapter_path=adapter_path) dag, parsed_query = parser.parse_with_query(text, query) result = { "dag": dag, "query": parsed_query, "variables": dag.variable_names(), "edges": dag.edges, "num_nodes": dag.num_nodes, "num_edges": dag.num_edges, } # Generate tensors if torch is available try: tensors = dag_to_causal_eng_input(dag, parsed_query, device=device) result["tensors"] = tensors except ImportError: result["tensors"] = None return result