Brent Bolleman
initial build
085f6c4
Raw History Blame Contribute Delete
3.51 kB
"""GAT symbol-spotting model (T020, research R4).
A two-layer Graph Attention Network over the attributed drawing graph:
node = entity, output = semantic type logits. Weights are pinned artifacts —
loaded in eval mode and hash-checked against models/meta.json so a silent
weight swap cannot change outputs unnoticed (Constitution IV, R11).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import torch
from torch_geometric.nn import GATConv
from cadmorph.classify.features import CLASSES, FEATURE_DIM
from cadmorph.determinism import check_weights, file_sha256
CLASSIFIER_WEIGHTS = "gat_classifier.pt"
META_FILE = "meta.json"
def models_dir() -> Path:
override = os.environ.get("CADMORPH_MODELS_DIR")
if override:
return Path(override)
return Path(__file__).resolve().parents[3] / "models"
class GATClassifier(torch.nn.Module):
"""GAT with a self-feature skip path.
Pure stacked attention over kNN-proximity edges over-smooths: an
entity's own vector-exact features (size, stroke, segment structure)
are what identify its symbol class, while attention supplies
neighborhood context. The head sees both, concatenated.
"""
def __init__(self, in_dim: int = FEATURE_DIM, hidden: int = 32, heads: int = 4) -> None:
super().__init__()
self.lin_self = torch.nn.Linear(in_dim, hidden * heads)
self.conv1 = GATConv(in_dim, hidden, heads=heads)
self.conv2 = GATConv(hidden * heads, hidden, heads=1)
self.head = torch.nn.Linear(hidden * heads + hidden, len(CLASSES))
def forward(self, x: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
h_self = torch.nn.functional.relu(self.lin_self(x))
h_graph = torch.nn.functional.elu(self.conv1(x, edge_index))
h_graph = torch.nn.functional.elu(self.conv2(h_graph, edge_index))
return self.head(torch.cat([h_self, h_graph], dim=1))
def save_pinned(model: torch.nn.Module, weights_name: str, directory: Path | None = None) -> str:
"""Save weights and record their hash in meta.json; returns the hash."""
directory = directory or models_dir()
directory.mkdir(parents=True, exist_ok=True)
path = directory / weights_name
torch.save(model.state_dict(), path)
digest = file_sha256(path)
meta_path = directory / META_FILE
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
meta[weights_name] = digest
meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True), encoding="utf-8")
return digest
def pinned_hash(weights_name: str, directory: Path | None = None) -> str:
directory = directory or models_dir()
meta = json.loads((directory / META_FILE).read_text(encoding="utf-8"))
return meta[weights_name]
def load_pinned(
model: torch.nn.Module, weights_name: str, directory: Path | None = None
) -> torch.nn.Module:
"""Load hash-checked weights into ``model`` and freeze it in eval mode."""
directory = directory or models_dir()
path = directory / weights_name
check_weights(path, pinned_hash(weights_name, directory))
model.load_state_dict(torch.load(path, map_location="cpu", weights_only=True))
model.eval()
for param in model.parameters():
param.requires_grad_(False)
return model
def load_classifier(directory: Path | None = None) -> GATClassifier:
return load_pinned(GATClassifier(), CLASSIFIER_WEIGHTS, directory) # type: ignore[return-value]