"""Shared model constants, hub routing helpers, and heterogeneous GAT blocks.""" import dgl import torch import torch.nn as nn import torch.nn.functional as F from dgl.nn import GATConv EDGE_TYPE_MAPPING = { "ret": 0, "jumptable": 1, "indirectcall": 3, "tailcall": 2, } EDGE_TYPE_TO_FNHASH = { "ret": "trainrethash", "jumptable": "trainjthash", "tailcall": "trainitchash", "indirectcall": "trainicallhash", } TASK_TO_CANDIDATE_KEY = { "indirectcall": "icall", "tailcall": "itailcall", "ret": "ret", "jumptable": "jumptable", "icall": "icall", "itailcall": "itailcall", } BASE_RELATION_TYPES = ( "code2code_edges", "code2funchead", "codecall_edges", "codejump_edges", "codexrefcode_edges", "codexrefdata_edges", "dataxrefcode_edges", "dataxrefdata_edges", "code_to_gch", "gch_to_code", "data_to_gdh", "gdh_to_data", "gch_to_gdh", "gdh_to_gch", ) PAPER_HUB_RELATION_TYPES = ( "src_to_gch", "gch_to_src", "dst_to_gch", "gch_to_dst", "data_to_gdh", "gdh_to_data", "gch_to_gdh", "gdh_to_gch", ) PAPER_RELATION_TYPES = tuple( dict.fromkeys( tuple(rel for rel in BASE_RELATION_TYPES if rel not in { "code_to_gch", "gch_to_code", "data_to_gdh", "gdh_to_data", "gch_to_gdh", "gdh_to_gch", }) + PAPER_HUB_RELATION_TYPES ) ) XREF_RELATION_TYPES = { "codexrefcode_edges", "codexrefdata_edges", "dataxrefcode_edges", "dataxrefdata_edges", } PAPER_HUB_INCOMING_RELATIONS = { "gch_to_src", "gch_to_dst", "gdh_to_data", } HUB_KIND_GCH = 0 HUB_KIND_GDH = 1 MODEL_ARTIFACT_FORMAT = "icflownet_model_v1" def save_model_artifact(path, model, model_config=None): """Save weights together with the model configuration needed to load them.""" model_config = dict(model_config or {}) for attribute in ( "code_feat_dim", "data_feat_dim", "gdh_radius", "use_gch", "use_gdh", "task_aware_routing", "use_reverse_edges", "hidden_dim", "num_heads", "num_layers", "shared_layers", "task_layers", "dropout", "task_weights", "task_type", ): if hasattr(model, attribute): model_config.setdefault(attribute, getattr(model, attribute)) artifact = { "artifact_format": MODEL_ARTIFACT_FORMAT, "model_config": model_config, "model_state_dict": model.state_dict(), } decision_threshold = getattr(model, "decision_threshold", None) if decision_threshold is not None: artifact["decision_threshold"] = float(decision_threshold) torch.save(artifact, path) def load_model_artifact(model, path, map_location=None): """Load a versioned model artifact or training checkpoint.""" artifact = torch.load(path, map_location=map_location) metadata = {} if isinstance(artifact, dict) and artifact.get("artifact_format") == MODEL_ARTIFACT_FORMAT: model_config = dict(artifact.get("model_config", {})) # Older model files included this redundant field in model_config. model_config.pop("architecture_version", None) stored_gdh_radius = model_config.get("gdh_radius") requested_gdh_radius = getattr(model, "gdh_radius", None) if ( stored_gdh_radius is not None and requested_gdh_radius is not None and int(stored_gdh_radius) != int(requested_gdh_radius) ): raise RuntimeError( "Checkpoint GDH radius {} does not match requested radius {}.".format( stored_gdh_radius, requested_gdh_radius, ) ) state_dict = artifact["model_state_dict"] decision_threshold = float(artifact.get("decision_threshold", 0.5)) if not 0.0 <= decision_threshold <= 1.0: raise RuntimeError( "Checkpoint decision threshold must be between 0 and 1." ) model.decision_threshold = decision_threshold metadata = { "model_config": model_config, "artifact_format": artifact.get("artifact_format"), "decision_threshold": decision_threshold, } elif isinstance(artifact, dict) and "model_state_dict" in artifact: state_dict = artifact["model_state_dict"] metadata = { "artifact_format": "training_checkpoint", } else: raise RuntimeError( "Unsupported unversioned checkpoint. Expected a versioned model artifact " "or training checkpoint." ) try: model.load_state_dict(state_dict) except RuntimeError as exc: raise RuntimeError( "Failed to load checkpoint weights: {}".format(exc) ) from exc return metadata def normalize_task_name(task_name): """Map training task names to the candidate sidecar's keys.""" return TASK_TO_CANDIDATE_KEY.get(task_name, task_name) def extract_node_features(g): """Collect per-node-type input features using the repo's existing conventions.""" feat_dict = {} for ntype in g.ntypes: node_data = g.nodes[ntype].data if "featmean" in node_data: feat_dict[ntype] = node_data["featmean"] elif "feat" in node_data: feat_dict[ntype] = node_data["feat"] elif ntype == "hub" and "hub_kind" in node_data: # Paper-exact hubs use nn.Embedding rather than binary-derived input # features. The model consumes hub_kind directly. continue elif g.num_nodes(ntype) == 0: continue else: raise RuntimeError( f"Missing 'featmean' or 'feat' in node features for node type '{ntype}'." ) if "code" not in feat_dict: raise RuntimeError("Missing required 'code' node features.") return feat_dict def add_paper_data_structural_features(g, feat_dict): """Append normalized base-graph in/out degrees to saved data features. Released graphs persist the normalized-address feature only. The paper also describes lightweight structural data features, so paper_exact derives the available in/out-degree signals at runtime without changing the saved graph. Hub edges are excluded so these features remain properties of the base ACFG. """ if "data" not in feat_dict or "data" not in g.ntypes: return feat_dict data_features = feat_dict["data"] num_data_nodes = g.num_nodes("data") in_degree = data_features.new_zeros(num_data_nodes) out_degree = data_features.new_zeros(num_data_nodes) for canonical_etype in g.canonical_etypes: src_type, rel_type, dst_type = canonical_etype if rel_type in PAPER_HUB_RELATION_TYPES: continue if src_type == "data": out_degree += g.out_degrees(etype=canonical_etype).to( device=data_features.device, dtype=data_features.dtype, ) if dst_type == "data": in_degree += g.in_degrees(etype=canonical_etype).to( device=data_features.device, dtype=data_features.dtype, ) def normalize_degree(values): values = torch.log1p(values) maximum = values.max() if values.numel() else values.new_zeros(()) return values / maximum.clamp_min(1.0) structural = torch.stack( (normalize_degree(in_degree), normalize_degree(out_degree)), dim=1, ) augmented = dict(feat_dict) augmented["data"] = torch.cat((data_features, structural), dim=1) return augmented def _valid_node_ids(base_graph, values, ntype): """Return in-range integer node ids without trusting sidecar contents.""" if ntype not in base_graph.ntypes: return set() max_nodes = base_graph.num_nodes(ntype) valid_ids = set() for value in values or (): try: node_id = int(value) except (TypeError, ValueError): continue if 0 <= node_id < max_nodes: valid_ids.add(node_id) return valid_ids def collect_xref_data_neighborhood(base_graph, code_candidates, radius=2): """Collect data nodes within an undirected xRef-only hop radius. The paper defines GDH pruning by shortest-path distance in the recovered xRef subgraph. Existing artifact generation treated code->data and data->code xRefs symmetrically for the one-hop case, so this implementation preserves that convention while extending it to a real bounded BFS. """ if radius < 1 or "code" not in base_graph.ntypes or "data" not in base_graph.ntypes: return set() start_nodes = { ("code", node_id) for node_id in _valid_node_ids(base_graph, code_candidates, "code") } if not start_nodes: return set() adjacency = {} def add_neighbor(left, right): adjacency.setdefault(left, set()).add(right) for canonical_etype in base_graph.canonical_etypes: src_type, rel_type, dst_type = canonical_etype if rel_type not in XREF_RELATION_TYPES: continue src_nodes, dst_nodes = base_graph.edges(etype=canonical_etype) src_values = src_nodes.detach().cpu().tolist() dst_values = dst_nodes.detach().cpu().tolist() for src_id, dst_id in zip(src_values, dst_values): src_key = (src_type, int(src_id)) dst_key = (dst_type, int(dst_id)) add_neighbor(src_key, dst_key) add_neighbor(dst_key, src_key) visited = set(start_nodes) frontier = set(start_nodes) data_nodes = set() for _depth in range(1, radius + 1): next_frontier = set() for node_key in frontier: for neighbor in adjacency.get(node_key, ()): if neighbor in visited: continue visited.add(neighbor) next_frontier.add(neighbor) if neighbor[0] == "data": data_nodes.add(neighbor[1]) frontier = next_frontier if not frontier: break return data_nodes def _cached_xref_data_neighborhood( base_graph, candidate_meta, cache_name, code_candidates, radius, ): """Cache topology-only GDH membership inside an in-memory metadata dict.""" runtime_cache = candidate_meta.setdefault("_paper_exact_runtime_cache", {}) signature = ( int(radius), int(base_graph.num_nodes("code")) if "code" in base_graph.ntypes else 0, int(base_graph.num_nodes("data")) if "data" in base_graph.ntypes else 0, tuple(sorted(int(node_id) for node_id in code_candidates)), ) cached = runtime_cache.get(cache_name) if cached is not None and cached.get("signature") == signature: return set(cached.get("data_candidates", ())) data_candidates = collect_xref_data_neighborhood( base_graph, code_candidates, radius=radius, ) runtime_cache[cache_name] = { "signature": signature, "data_candidates": sorted(data_candidates), } return data_candidates def _task_code_candidate_sets(base_graph, candidate_meta, candidate_key): code_meta = candidate_meta.get("code_candidates", {}) src_candidates = _valid_node_ids( base_graph, code_meta.get("src", {}).get(candidate_key, []), "code", ) dst_candidates = _valid_node_ids( base_graph, code_meta.get("dst", {}).get(candidate_key, []), "code", ) if candidate_key == "jumptable": dst_by_src = code_meta.get("dst_by_src", {}).get("jumptable", {}) for dst_nodes in dst_by_src.values(): dst_candidates.update(_valid_node_ids(base_graph, dst_nodes, "code")) return src_candidates, dst_candidates def _build_paper_augmented_graph( base_graph, src_candidates, dst_candidates, data_candidates, use_gch, use_gdh, ): """Materialize the paper's role-separated Dual-Hub topology.""" use_gch = bool(use_gch and "code" in base_graph.ntypes) use_gdh = bool(use_gdh and "data" in base_graph.ntypes) if not use_gch and not use_gdh: return base_graph if "hub" in base_graph.ntypes: raise ValueError("Paper hub augmentation expects an unaugmented base graph.") if not src_candidates and not dst_candidates and not data_candidates: return base_graph graph_device = base_graph.device graph_dtype = base_graph.idtype graph_data = {} for canonical_etype in base_graph.canonical_etypes: graph_data[canonical_etype] = base_graph.edges(etype=canonical_etype) num_nodes_dict = {ntype: base_graph.num_nodes(ntype) for ntype in base_graph.ntypes} hub_node_ids = {} hub_kinds = [] if use_gch: hub_node_ids["gch"] = len(hub_kinds) hub_kinds.append(HUB_KIND_GCH) if use_gdh: hub_node_ids["gdh"] = len(hub_kinds) hub_kinds.append(HUB_KIND_GDH) num_nodes_dict["hub"] = len(hub_kinds) def ids_tensor(values): return torch.tensor(list(values), dtype=graph_dtype, device=graph_device) def add_code_hub_relation(nodes, forward_rel, reverse_rel): if not nodes: return sorted_nodes = sorted(int(node_id) for node_id in nodes) gch_id = hub_node_ids["gch"] graph_data[("code", forward_rel, "hub")] = ( ids_tensor(sorted_nodes), ids_tensor([gch_id] * len(sorted_nodes)), ) graph_data[("hub", reverse_rel, "code")] = ( ids_tensor([gch_id] * len(sorted_nodes)), ids_tensor(sorted_nodes), ) if use_gch: add_code_hub_relation(src_candidates, "src_to_gch", "gch_to_src") add_code_hub_relation(dst_candidates, "dst_to_gch", "gch_to_dst") if use_gdh and data_candidates: sorted_data = sorted(int(node_id) for node_id in data_candidates) gdh_id = hub_node_ids["gdh"] graph_data[("data", "data_to_gdh", "hub")] = ( ids_tensor(sorted_data), ids_tensor([gdh_id] * len(sorted_data)), ) graph_data[("hub", "gdh_to_data", "data")] = ( ids_tensor([gdh_id] * len(sorted_data)), ids_tensor(sorted_data), ) if use_gch and use_gdh: graph_data[("hub", "gch_to_gdh", "hub")] = ( ids_tensor([hub_node_ids["gch"]]), ids_tensor([hub_node_ids["gdh"]]), ) graph_data[("hub", "gdh_to_gch", "hub")] = ( ids_tensor([hub_node_ids["gdh"]]), ids_tensor([hub_node_ids["gch"]]), ) augmented_graph = dgl.heterograph( graph_data, num_nodes_dict=num_nodes_dict, idtype=graph_dtype, device=graph_device, ) for ntype in base_graph.ntypes: for feat_name, feat_tensor in base_graph.nodes[ntype].data.items(): augmented_graph.nodes[ntype].data[feat_name] = feat_tensor augmented_graph.nodes["hub"].data["hub_kind"] = torch.tensor( hub_kinds, dtype=torch.long, device=graph_device, ) return augmented_graph def build_paper_task_augmented_graph( base_graph, candidate_meta, task_name, use_gch=False, use_gdh=False, task_aware_routing=True, gdh_radius=2, ): """Build the paper-exact single-task topology from a reusable base graph.""" if not use_gch and not use_gdh: return base_graph if not candidate_meta: return base_graph candidate_key = normalize_task_name(task_name) src_candidates, dst_candidates = _task_code_candidate_sets( base_graph, candidate_meta, candidate_key, ) # Jump-table dispatch sites still route to the source side of the code # hub. Only their destination candidates are excluded by the paper's # task-aware topology. Keep this identical to the unified MTL path below. if task_aware_routing and candidate_key == "jumptable": dst_candidates = set() data_candidates = _cached_xref_data_neighborhood( base_graph, candidate_meta, "single:{}".format(candidate_key), src_candidates | dst_candidates, gdh_radius, ) if use_gdh else set() return _build_paper_augmented_graph( base_graph, src_candidates, dst_candidates, data_candidates, use_gch, use_gdh, ) def build_paper_mtl_augmented_graph( base_graph, candidate_meta, use_gch=False, use_gdh=False, task_aware_routing=True, gdh_radius=2, ): """Build one unified paper MTL graph shared by all task branches.""" if not use_gch and not use_gdh: return base_graph if not candidate_meta: return base_graph src_candidates = set() dst_candidates = set() for candidate_key in ("icall", "itailcall", "ret", "jumptable"): task_src, task_dst = _task_code_candidate_sets( base_graph, candidate_meta, candidate_key, ) # Keep jump-table sources: angr cannot reliably distinguish them from # indirect-tail-call sources, and both belong to the shared # ``jumptable_itailcall`` source group recorded in hub metadata. src_candidates.update(task_src) if candidate_key != "jumptable" or not task_aware_routing: dst_candidates.update(task_dst) data_candidates = _cached_xref_data_neighborhood( base_graph, candidate_meta, "mtl", src_candidates | dst_candidates, gdh_radius, ) if use_gdh else set() return _build_paper_augmented_graph( base_graph, src_candidates, dst_candidates, data_candidates, use_gch, use_gdh, ) class PaperHeteroGATLayer(nn.Module): """Paper-exact HGAT layer with local-conditioned hub-gated fusion.""" def __init__( self, in_dim, out_dim, num_heads, dropout=0.4, use_residual=True, use_reverse_edges=False, ): super(PaperHeteroGATLayer, self).__init__() assert out_dim % num_heads == 0, "out_dim must be divisible by num_heads" self.in_dim = in_dim self.out_dim = out_dim self.num_heads = num_heads # ``out_dim`` is the total hidden width. Split it evenly across the # attention heads and concatenate their outputs back to ``out_dim``. self.head_dim = out_dim // num_heads self.use_reverse_edges = use_reverse_edges self.use_residual = use_residual relation_types = list(PAPER_RELATION_TYPES) if use_reverse_edges: relation_types.extend( "rev_" + rel_type for rel_type in PAPER_RELATION_TYPES if rel_type not in PAPER_HUB_RELATION_TYPES ) relation_types = tuple(dict.fromkeys(relation_types)) self.gat_layers = nn.ModuleDict({ rel_type: GATConv( in_dim, self.head_dim, num_heads, dropout, dropout, allow_zero_in_degree=True, ) for rel_type in relation_types }) if use_residual: self.residuals = nn.ModuleDict({ ntype: nn.Linear(in_dim, out_dim) for ntype in ("code", "data") }) # The implementation stores both virtual nodes under DGL's compact # ``hub`` node type, but retains the paper's distinct gcode/gdata # residual transforms through their hub_kind identities. self.hub_residuals = nn.ModuleList([ nn.Linear(in_dim, out_dim), nn.Linear(in_dim, out_dim), ]) ordinary_types = ("code", "data") self.hub_gates = nn.ModuleDict({ ntype: nn.Linear(out_dim + in_dim, 1) for ntype in ordinary_types }) self.hub_scales = nn.ParameterDict({ ntype: nn.Parameter(torch.ones(())) for ntype in ordinary_types }) self.layer_norms = nn.ModuleDict({ ntype: nn.LayerNorm(out_dim) for ntype in ordinary_types }) def _zero_message(self, nfeat): return nfeat.new_zeros((nfeat.shape[0], self.out_dim)) def _sum_messages(self, messages, nfeat): if not messages: return self._zero_message(nfeat) return torch.stack(messages, dim=0).sum(dim=0) def forward(self, g, feat_dict): local_messages = {ntype: [] for ntype in feat_dict} hub_messages = {ntype: [] for ntype in feat_dict} for canonical_etype in g.canonical_etypes: src_type, rel_type, dst_type = canonical_etype if src_type not in feat_dict or dst_type not in feat_dict: continue if rel_type.startswith("rev_") and not self.use_reverse_edges: continue if rel_type not in self.gat_layers or g.num_edges(canonical_etype) == 0: continue relation_heads = self.gat_layers[rel_type]( g[canonical_etype], (feat_dict[src_type], feat_dict[dst_type]), ) relation_message = relation_heads.flatten(start_dim=1) if dst_type in ("code", "data") and rel_type in PAPER_HUB_INCOMING_RELATIONS: hub_messages[dst_type].append(relation_message) else: local_messages[dst_type].append(relation_message) final_features = {} for ntype, nfeat in feat_dict.items(): local_message = self._sum_messages(local_messages[ntype], nfeat) if self.use_residual and ntype in self.residuals: residual = self.residuals[ntype](nfeat) elif self.use_residual and ntype == "hub": hub_kinds = g.nodes["hub"].data["hub_kind"].long() residual = self._zero_message(nfeat) for kind, transform in enumerate(self.hub_residuals): kind_mask = (hub_kinds == kind).to(nfeat.dtype).unsqueeze(-1) residual = residual + kind_mask * transform(nfeat) else: residual = self._zero_message(nfeat) if ntype in ("code", "data"): hub_message = self._sum_messages(hub_messages[ntype], nfeat) gate = torch.sigmoid( self.hub_gates[ntype](torch.cat([local_message, nfeat], dim=-1)) ) combined = ( local_message + self.hub_scales[ntype] * gate * hub_message + residual ) final_features[ntype] = F.elu(self.layer_norms[ntype](combined)) else: # Hub nodes use the standard relation-aware update without the # ordinary-node local/hub gate from Equations (3)-(4). final_features[ntype] = F.elu(local_message + residual) return final_features