Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY matgraph ./matgraph
RUN pip install --no-cache-dir -e .
EXPOSE 8000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For production deployments, it's generally recommended to install packages in non-editable mode (pip install --no-cache-dir .) to ensure a cleaner and more predictable build. Editable installs (-e .) are more suited for development. Consider switching this for a production-optimized Dockerfile if this image is intended for deployment.

ENV MATGRAPH_CACHE_DIR=/data/cache
CMD ["uvicorn","matgraph.graphql_app:app","--host","0.0.0.0","--port","8000"]
77 changes: 41 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ Researchers spend weeks writing boilerplate to fetch crystal data, engineer feat
| Problem | MatGraph solution |
|---|---|
| Fetching crystal structures from Materials Project | `sdk.predict("LiFePO4")` |
| Training CGCNN / MEGNet / M3GNet from scratch | Pre-wired architectures, ready to run |
| Exploring hypothetical new materials | `matgraph substitute LiFePO4 Li Na` |
| Running MatGL M3GNet inference | `matgraph predict LiFePO4 --model m3gnet` (only M3GNet ships in 2.x) |
| Exploring hypothetical new materials (heuristic) | `matgraph substitute LiFePO4 Li Na` (ML-guided, not GNoME-scale) |
| Simulating XRD patterns | `matgraph xrd LiFePO4` |
| Serving predictions to a web app | Async GraphQL API with API key auth |
| Caching repeated queries | Built-in SQLite cache, zero config |
| Serving predictions to a web app | Async GraphQL + REST `/v1/predict` with hashed API keys |
| Caching repeated queries | Reproducible SQLite cache (structure_hash + model_version) |

---

Expand Down Expand Up @@ -51,17 +51,17 @@ export MP_API_KEY="your_key_here"
# Predict band gap and formation energy
matgraph predict LiFePO4

# Use a different model architecture
# M3GNet is the only model shipped in 2.x (cgcnn/megnet removed until real checkpoints ship)
matgraph predict LiFePO4 --model m3gnet

# Discover new materials via elemental substitution
# ML-guided heuristic discovery (not GNoME-scale)
matgraph substitute LiFePO4 Li Na

# Simulate X-Ray Diffraction pattern
matgraph xrd LiFePO4

# Evaluate model accuracy (MAE) against ground truth
matgraph evaluate LiFePO4 --model megnet
# Evaluate formation-energy MAE (band_gap unavailable — no UQ model)
matgraph evaluate LiFePO4 --model m3gnet

# Filter by physical constraints
matgraph predict LiFePO4 --min-gap 1.5 --crystal-system Cubic
Expand Down Expand Up @@ -91,9 +91,9 @@ print("Stable" if discovery["is_more_stable"] else "Unstable")
# XRD simulation
xrd = sdk.xrd("LiFePO4")

# Model evaluation
metrics = sdk.evaluate("LiFePO4", model="megnet")
print(f"Band gap MAE: {metrics['band_gap_mae']}")
# Model evaluation — band_gap MAE is None until a real band-gap model ships
metrics = sdk.evaluate("LiFePO4", model="m3gnet")
print(f"Formation energy MAE: {metrics['formation_energy_mae']}")
```

### GraphQL API
Expand Down Expand Up @@ -126,21 +126,22 @@ Or open `http://localhost:8000/graphql` for the interactive GraphiQL playground.

## Features

### Deep Learning Models
### Deep Learning Models (2.x — M3GNet only)

| Model | Predicts | Architecture |
|---|---|---|
| **CGCNN** | Band gap, Formation energy | Crystal Graph Convolutional Neural Network |
| **MEGNet** | Band gap, Formation energy | MatErials Graph Network |
| **M3GNet** | Energy, Forces, Stresses | Multi-body interaction universal potential |
| Model | Predicts | Architecture | Status |
|---|---|---|---|
| **M3GNet** | Energy, Forces, Stresses, Formation energy (via MatGL) | Multi-body universal potential | ✅ Ships (`M3GNet-PES-MatPES-PBE-2025.2`) |
| CGCNN/MEGNet | — | Graph networks | ❌ Removed in 2.0 until real checkpoints/benchmarks ship (was alias to M3GNet) |

### Generative Discovery (GNoME-inspired)
> **Band gap:** no ML band-gap model ships — `predicted_band_gap=None`, `band_gap_source="mp_experimental"`, filter on `true_band_gap` only.

Inspired by [Google DeepMind's GNoME](https://deepmind.google/discover/blog/millions-of-new-materials-discovered-with-deep-learning/) paper. Substitute elements in known stable materials and predict whether the hypothetical new compound is thermodynamically stable -- without synthesizing it in a lab.
### ML-guided heuristic discovery (experimental)

Heuristic elemental substitution + simple GA ranking via M3GNet energies. Useful for triage, **not** GNoME-scale generative discovery.

```bash
matgraph substitute LiFePO4 Li Na
# Predicts: NaFePO4 stability vs LiFePO4
# Predicts: NaFePO4 stability vs LiFePO4 (heuristic, validate with DFT)
```

### XRD Simulation
Expand All @@ -151,25 +152,23 @@ Generate theoretical Cu-Ka X-Ray Diffraction patterns for any material. Useful f
matgraph xrd LiFePO4
```

### Built-in Cache
### Reproducible cache (2.0)

All API responses and predictions are automatically cached in a local SQLite database (`~/.matgraph_cache/cache.db`). Repeated queries return instantly. No external service required.
SQLite + WAL at `~/.matgraph_cache/cache.db` (override `MATGRAPH_CACHE_DIR`), key = `material_id+structure_hash+model+checkpoint+code_version+params`. Reproducibility via `provenance` field on every prediction.

```bash
matgraph cache stats # View cache size and entry count
matgraph cache clear # Wipe the cache
```

### API Key Authentication
### Hashed API keys (2.0)

Generate secure, multi-tenant API keys for the GraphQL server:
Keys are `mg_*`, stored as `sha256` with `scopes/expiry/revocation` in `~/.matgraph_keys.json` (override `MATGRAPH_AUTH_KEYS_FILE`). `MATGRAPH_API_KEY` master key still supported. Not multi-tenant authz — local research use.

```bash
matgraph auth generate --user "research-team-A"
```

Keys are prefixed with `mg_`, stored in `~/.matgraph_keys.json`, and validated on every request. You can also set a master key via the `MATGRAPH_API_KEY` environment variable.

### Dataset Export

Export predictions to CSV or JSON for use in pandas, scikit-learn, or any ML pipeline. Optionally export 3D crystal structures as `.cif` files.
Expand All @@ -184,16 +183,22 @@ matgraph predict LiFePO4 --save results.json --format json --cif

```
matgraph/
__init__.py # Top-level SDK export
sdk.py # Python SDK (MatGraphSDK class)
cli.py # Typer CLI with Rich formatting
core.py # Pipeline orchestration and feature extraction
cgcnn.py # Crystal Graph Convolutional Neural Network
megnet.py # MatErials Graph Network
m3gnet.py # M3GNet Universal Potential
graphql_app.py # FastAPI + Strawberry GraphQL server
auth.py # API key generation and validation
cdn.py # SQLite cache layer
__init__.py
sdk.py # SDK (predict/substitute/xrd/... + DataFrame)
cli.py # Typer CLI
core.py # Orchestration shim (re-exports data/models/...)
client.py # Materials Project client
models.py # M3GNet registry (settings.pes_model)
schemas.py # Pydantic validation, no hardcodes
settings.py # Central MATGRAPH_* settings
cdn.py # WAL SQLite cache
auth.py # sha256 keys + scopes/expiry
ga.py # Heuristic GA (param-driven)
graphql_app.py # GraphQL + REST /v1/predict + /health
data/ # (v2 split) materials_project
simulation/ # xrd/phonon/relax
dft/ # vasp/qe input generation
properties/ # stability/elastic/...
```

### Tech Stack
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
api:
build: .
ports: ["8000:8000"]
environment:
- MP_API_KEY=${MP_API_KEY}
- MATGRAPH_CACHE_DIR=/data/cache
- MATGRAPH_GRAPHQL_DEFAULT_LIMIT=10
volumes:
- matcache:/data/cache

volumes:
matcache:
2 changes: 2 additions & 0 deletions matgraph/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from matgraph.graphql_app import app
__all__ = ["app"]
93 changes: 68 additions & 25 deletions matgraph/auth.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,88 @@
import json
import secrets
import os
import hashlib
import time
from pathlib import Path
from typing import Optional

KEYS_FILE = Path.home() / ".matgraph_keys.json"
def _keys_file() -> Path:
from matgraph.settings import settings
return settings.auth_keys_file

def _prefix() -> str:
from matgraph.settings import settings
return settings.auth_key_prefix

def load_keys() -> dict:
if not KEYS_FILE.exists():
f = _keys_file()
if not f.exists():
return {}
with open(KEYS_FILE, "r") as f:
with open(f, "r") as fh:
try:
return json.load(f)
return json.load(fh)
except json.JSONDecodeError:
return {}

def save_keys(keys: dict):
with open(KEYS_FILE, "w") as f:
json.dump(keys, f, indent=4)
f = _keys_file()
f.parent.mkdir(parents=True, exist_ok=True)
with open(f, "w") as fh:
json.dump(keys, fh, indent=4)
try:
f.chmod(0o600)
except Exception:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try...except Exception block for f.chmod(0o600) is quite broad. While chmod can fail on certain filesystems (e.g., FAT32, network shares), it's good practice to either log the specific exception (e.g., logging.warning("Could not set permissions on auth keys file: %s", e)) or catch more specific exceptions if known, to avoid masking other potential issues.

pass

def _hash_key(api_key: str) -> str:
return hashlib.sha256(api_key.encode()).hexdigest()

def generate_api_key(user_name: str) -> str:
"""Generates a secure API key for a user and saves it."""
def generate_api_key(user_name: str, ttl_days: Optional[int] = None, scopes: Optional[list] = None) -> str:
"""Generates a secure API key, stores only hash. No plaintext."""
from matgraph.settings import settings
if ttl_days is None:
ttl_days = settings.auth_default_ttl_days
if scopes is None:
scopes = ["read:predict","read:phonon","read:elastic"]
keys = load_keys()
new_key = "mg_" + secrets.token_urlsafe(24)
keys[new_key] = {
"user": user_name,
"active": True
}
raw = _prefix() + secrets.token_urlsafe(24)
h = _hash_key(raw)
expires_at = time.time() + ttl_days*86400 if ttl_days else None
keys[h] = {"user": user_name, "active": True, "scopes": scopes, "created_at": time.time(), "expires_at": expires_at, "prefix": raw[:8]+"..."}
save_keys(keys)
return new_key
return raw

def is_valid_key(api_key: str) -> bool:
"""Checks if the API key is valid."""
# Allow master key from env for dev purposes
master_key = os.environ.get("MATGRAPH_API_KEY")
if master_key and api_key == master_key:
def is_valid_key(api_key: str, required_scope: Optional[str] = None) -> bool:
master = os.environ.get("MATGRAPH_API_KEY")
if master and api_key == master:
return True

# support legacy plaintext keys file for migration
h = _hash_key(api_key)
keys = load_keys()
key_info = keys.get(api_key)
if key_info and key_info.get("active", False):
return True

return False
# legacy: if keys contain plaintext key directly, migrate check
if api_key in keys:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if api_key in keys: block allows for legacy plaintext API keys to still be valid. While this is a good temporary measure for migration, it means plaintext keys are still being stored and checked, which undermines the security improvement of hashing. I recommend adding a deprecation warning for these legacy keys and planning for their eventual removal after a suitable migration period. Users should be prompted to regenerate their keys.

info = keys[api_key]
else:
info = keys.get(h)
if not info or not info.get("active", False):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic if api_key in keys: allows legacy plaintext API keys to be directly present in the ~/.matgraph_keys.json file. While this provides a smooth migration path, it means that if an old keys.json file containing plaintext keys is compromised, those keys would still be valid. Consider adding a strong warning during migration or a mechanism to automatically hash and update legacy keys to improve security posture.

exp = info.get("expires_at")
if exp and time.time() > exp:
return False
if required_scope and required_scope not in info.get("scopes", []):
return False
return True

def revoke_key(api_key: str) -> bool:
h = _hash_key(api_key)
keys = load_keys()
# try hash or plaintext
target = h if h in keys else (api_key if api_key in keys else None)
if not target:
return False
keys[target]["active"] = False
save_keys(keys)
return True

def list_keys() -> dict:
return load_keys()
40 changes: 31 additions & 9 deletions matgraph/cdn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,30 @@
from pathlib import Path
from typing import Optional, Any

CACHE_DIR = Path.home() / ".matgraph_cache"
CACHE_DB = CACHE_DIR / "cache.db"
def _cache_dir() -> Path:
from matgraph.settings import settings, cache_db_path
return settings.cache_dir

def _cache_db() -> Path:
from matgraph.settings import cache_db_path
return cache_db_path()

CACHE_DIR = _cache_dir()
CACHE_DB = _cache_db()


def _init_db() -> sqlite3.Connection:
"""Initialize the SQLite cache database."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(CACHE_DB))
from matgraph.settings import settings
db = _cache_db()
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db))
# WAL for concurrency, no hardcode
try:
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
except Exception:
pass
conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
Expand All @@ -43,11 +59,14 @@ def _get_cache_key(prefix: str, **kwargs) -> str:
return f"{prefix}:{digest}"


def cache_get(prefix: str, ttl: int = 3600, **kwargs) -> Optional[Any]:
def cache_get(prefix: str, ttl: Optional[int] = None, **kwargs) -> Optional[Any]:
"""
Retrieve cached result if it exists and is within TTL.
Returns None on cache miss.
TTL comes from settings.get_ttl(prefix) if not passed.
"""
from matgraph.settings import get_ttl
if ttl is None:
ttl = get_ttl(prefix)
key = _get_cache_key(prefix, **kwargs)
try:
conn = _init_db()
Expand Down Expand Up @@ -97,14 +116,17 @@ def cache_clear():
def cache_stats() -> dict:
"""Return cache statistics."""
try:
from matgraph.settings import cache_db_path
db = cache_db_path()
conn = _init_db()
total = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
size_bytes = CACHE_DB.stat().st_size if CACHE_DB.exists() else 0
size_bytes = db.stat().st_size if db.exists() else 0
conn.close()
return {
"entries": total,
"size_mb": round(size_bytes / (1024 * 1024), 2),
"location": str(CACHE_DB),
"location": str(db),
}
except Exception:
return {"entries": 0, "size_mb": 0, "location": str(CACHE_DB)}
from matgraph.settings import cache_db_path
return {"entries": 0, "size_mb": 0, "location": str(cache_db_path())}
Loading
Loading