mattergraph-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """MatterGraph demo API package."""
@@ -0,0 +1,15 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_prefix="MATTERGRAPH_", extra="ignore")
9
+
10
+ demo_data: Path = Path(
11
+ os.environ.get("MATTERGRAPH_DEMO_DATA", "data/demo/materials_sample.jsonl")
12
+ )
13
+
14
+
15
+ settings = Settings()
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,6 @@
1
+ # Placeholder: SQLAlchemy models for setups that persist material records.
2
+
3
+ from typing import Any
4
+
5
+ # Not used in the open-source demo, which is JSONL-in-memory.
6
+ StubModel: Any = object
@@ -0,0 +1,4 @@
1
+ # Placeholder: database session factory for a user-provided persistence backend.
2
+
3
+ def get_session() -> None:
4
+ return None
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import JSONResponse
6
+ from mattergraph_connectors.local_import import ImportLimitError, ImportValidationError
7
+
8
+ from mattergraph_api.routes import datasets, demo, materials, scores, search, simulations, workflows
9
+ from mattergraph_api.services.dataset_registry import (
10
+ DatasetBusyError,
11
+ DatasetCapacityError,
12
+ DatasetNotFoundError,
13
+ )
14
+
15
+ app = FastAPI(
16
+ title="MatterGraph API",
17
+ description=(
18
+ "Evidence-first demo API for provenanced materials records, reciprocal graph summaries, "
19
+ "audited ranking, and explicitly labeled simulation evidence."
20
+ ),
21
+ version="0.1.0",
22
+ )
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"],
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ app.include_router(materials.router, tags=["materials"])
32
+ app.include_router(search.router, tags=["search"])
33
+ app.include_router(scores.router, tags=["scores"])
34
+ app.include_router(simulations.router, tags=["simulations"])
35
+ app.include_router(workflows.router, tags=["workflows"])
36
+ app.include_router(demo.router, tags=["demo"])
37
+ app.include_router(datasets.router, tags=["datasets"])
38
+
39
+
40
+ @app.exception_handler(DatasetNotFoundError)
41
+ def dataset_not_found(_request: Request, error: DatasetNotFoundError) -> JSONResponse:
42
+ return JSONResponse(
43
+ status_code=404,
44
+ content={
45
+ "detail": str(error),
46
+ "code": "dataset_evicted" if error.evicted else "dataset_not_found",
47
+ "dataset_id": error.dataset_id,
48
+ },
49
+ )
50
+
51
+
52
+ @app.exception_handler(DatasetBusyError)
53
+ def dataset_busy(_request: Request, error: DatasetBusyError) -> JSONResponse:
54
+ return JSONResponse(status_code=409, content={"detail": str(error), "code": "dataset_busy"})
55
+
56
+
57
+ @app.exception_handler(ImportLimitError)
58
+ @app.exception_handler(DatasetCapacityError)
59
+ def dataset_limit(_request: Request, error: Exception) -> JSONResponse:
60
+ return JSONResponse(status_code=413, content={"detail": str(error), "code": "dataset_limit"})
61
+
62
+
63
+ @app.exception_handler(ImportValidationError)
64
+ def invalid_import(_request: Request, error: ImportValidationError) -> JSONResponse:
65
+ return JSONResponse(
66
+ status_code=422,
67
+ content={
68
+ "detail": str(error),
69
+ "code": "invalid_import",
70
+ "report": error.report.model_dump(mode="json"),
71
+ },
72
+ )
73
+
74
+
75
+ @app.get("/health")
76
+ def health() -> dict[str, str]:
77
+ return {"status": "ok"}
File without changes
@@ -0,0 +1 @@
1
+ """API routes."""
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from fastapi import APIRouter, Response
6
+ from mattergraph import MaterialStore
7
+ from mattergraph.datasets import DeduplicationBasis, MatterGraphDataset
8
+ from mattergraph_connectors.local_import import (
9
+ DatasetImportMapping,
10
+ ImportReport,
11
+ ImportResult,
12
+ import_local_content,
13
+ inspect_local_content,
14
+ )
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from mattergraph_api.services.dataset_registry import dataset_registry
18
+ from mattergraph_api.services.demo_service import graph_summary
19
+
20
+ router = APIRouter(prefix="/datasets")
21
+
22
+
23
+ class InspectRequest(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ filename: str
27
+ format: Literal["csv", "jsonl"]
28
+ content: str
29
+
30
+
31
+ class ImportRequest(InspectRequest):
32
+ mapping: DatasetImportMapping | None = None
33
+ error_policy: Literal["reject_file", "skip_invalid_rows"] = "reject_file"
34
+
35
+
36
+ class SlicePreviewRequest(BaseModel):
37
+ model_config = ConfigDict(extra="forbid")
38
+
39
+ include_elements: list[str] = Field(default_factory=list)
40
+ exclude_elements: list[str] = Field(default_factory=list)
41
+ max_nsites: int | None = Field(default=None, ge=1)
42
+ max_nelements: int | None = Field(default=None, ge=1)
43
+ target: str | None = None
44
+ deduplication_basis: DeduplicationBasis = "immutable_id"
45
+ allow_mixed_functionals: bool = False
46
+ allow_duplicate_records: bool = False
47
+
48
+
49
+ @router.get("")
50
+ def list_datasets() -> dict[str, Any]:
51
+ return {"datasets": dataset_registry.list(), "registry": dataset_registry.stats()}
52
+
53
+
54
+ @router.post("/inspect", response_model=ImportReport)
55
+ def inspect_dataset(request: InspectRequest) -> ImportReport:
56
+ return inspect_local_content(
57
+ filename=request.filename,
58
+ format=request.format,
59
+ content=request.content,
60
+ )
61
+
62
+
63
+ @router.post("/import", response_model=ImportResult)
64
+ def import_dataset(request: ImportRequest) -> ImportResult:
65
+ imported = import_local_content(
66
+ filename=request.filename,
67
+ format=request.format,
68
+ content=request.content,
69
+ mapping=request.mapping,
70
+ error_policy=request.error_policy,
71
+ )
72
+ dataset_registry.register(
73
+ imported.result.manifest,
74
+ imported.normalized_jsonl,
75
+ )
76
+ return imported.result.model_copy(
77
+ update={
78
+ "manifest": imported.result.manifest.model_copy(
79
+ update={"degraded": imported.result.manifest.degraded}
80
+ )
81
+ }
82
+ )
83
+
84
+
85
+ @router.get("/{dataset_id}")
86
+ def get_dataset(dataset_id: str) -> dict[str, Any]:
87
+ return dataset_registry.status(dataset_id)
88
+
89
+
90
+ @router.delete("/{dataset_id}")
91
+ def delete_dataset(dataset_id: str) -> dict[str, Any]:
92
+ manifest = dataset_registry.delete(dataset_id)
93
+ return {"deleted": True, "dataset_id": manifest.dataset_id}
94
+
95
+
96
+ @router.get("/{dataset_id}/export")
97
+ def export_dataset(dataset_id: str, format: Literal["jsonl"] = "jsonl") -> Response:
98
+ manifest, payload = dataset_registry.export(dataset_id)
99
+ return Response(
100
+ content=payload,
101
+ media_type="application/x-ndjson",
102
+ headers={
103
+ "Content-Disposition": f'attachment; filename="{manifest.dataset_id}.jsonl"',
104
+ "X-MatterGraph-Dataset-Id": manifest.dataset_id,
105
+ "X-MatterGraph-SHA256": manifest.normalized_sha256,
106
+ "X-MatterGraph-Record-Count": str(manifest.record_count),
107
+ },
108
+ )
109
+
110
+
111
+ @router.post("/{dataset_id}/slices/preview")
112
+ def preview_slice(dataset_id: str, request: SlicePreviewRequest) -> dict[str, Any]:
113
+ store = dataset_registry.materialize(dataset_id)
114
+ dataset = _dataset_from_store(dataset_id, store)
115
+ candidate = dataset.candidate_pool(
116
+ include=request.include_elements,
117
+ exclude=request.exclude_elements,
118
+ max_nsites=request.max_nsites,
119
+ max_nelements=request.max_nelements,
120
+ )
121
+ candidate_slice = candidate.create_slice(
122
+ "local_workbench_preview",
123
+ allow_mixed_functionals=request.allow_mixed_functionals,
124
+ allow_duplicate_records=request.allow_duplicate_records,
125
+ deduplication_basis=request.deduplication_basis,
126
+ target=request.target,
127
+ )
128
+ graph_ready = 0
129
+ graph_excluded = 0
130
+ material_ids = [str(value) for value in candidate_slice.frame["material_id"].tolist()]
131
+ materials_by_id = {material.material_id: material for material in store.materials}
132
+ for material_id in material_ids:
133
+ try:
134
+ graph_summary(materials_by_id[material_id], max_edges=0)
135
+ graph_ready += 1
136
+ except ValueError:
137
+ graph_excluded += 1
138
+ preview_columns = ["material_id", "formula", "nsites", "nelements"]
139
+ if request.target:
140
+ preview_columns.append(request.target)
141
+ frame = candidate_slice.frame
142
+ preview_columns = [column for column in preview_columns if column in frame.columns]
143
+ benchmark_preview = frame[preview_columns].head(20).where(frame.notna(), None).to_dict(
144
+ orient="records"
145
+ )
146
+ return {
147
+ "slice": candidate_slice.report(),
148
+ "material_ids": material_ids,
149
+ "graph_readiness": {"included_count": graph_ready, "excluded_count": graph_excluded},
150
+ "benchmark_preview": benchmark_preview,
151
+ }
152
+
153
+
154
+ def _dataset_from_store(dataset_id: str, store: MaterialStore) -> MatterGraphDataset:
155
+ records: list[dict[str, Any]] = []
156
+ property_columns: set[str] = set()
157
+ property_units: dict[str, str] = {}
158
+ for material in store.materials:
159
+ record: dict[str, Any] = {
160
+ "material_id": material.material_id,
161
+ "formula": material.formula,
162
+ "reduced_formula": material.reduced_formula,
163
+ "elements": material.elements,
164
+ "structure": material.structure,
165
+ "immutable_id": material.material_id,
166
+ "provenance": material.provenance,
167
+ }
168
+ for property_value in material.properties:
169
+ record[property_value.name] = property_value.value
170
+ property_columns.add(property_value.name)
171
+ if property_value.unit:
172
+ property_units.setdefault(property_value.name, property_value.unit)
173
+ records.append(record)
174
+ return MatterGraphDataset.from_records(
175
+ records,
176
+ source_dataset=dataset_id,
177
+ source_subset="local_import",
178
+ metadata={
179
+ "default_deduplication_basis": "immutable_id",
180
+ "property_columns": sorted(property_columns),
181
+ "property_units": property_units,
182
+ "provenance_fields": ["immutable_id", "provenance"],
183
+ },
184
+ )
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from mattergraph_api.services import store_service
8
+ from mattergraph_api.services.demo_service import (
9
+ DEFAULT_CONSTRAINTS,
10
+ DEFAULT_OBJECTIVES,
11
+ FIXTURE_DISCLAIMER,
12
+ FIXTURE_RELATIVE_PATH,
13
+ capability_catalog,
14
+ chgnet_state,
15
+ get_default_material_id,
16
+ get_default_scorecard,
17
+ get_demo_manifest,
18
+ graph_summary,
19
+ simulation_readiness,
20
+ )
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ @router.get("/capabilities")
26
+ def capabilities() -> dict[str, list[dict[str, Any]]]:
27
+ return {"capabilities": capability_catalog()}
28
+
29
+
30
+ @router.get("/demo/preflight")
31
+ def demo_preflight() -> dict[str, Any]:
32
+ store = store_service.get_store()
33
+ graph_ready = 0
34
+ graph_excluded = 0
35
+ graph_invalid = 0
36
+ for material in store.materials:
37
+ try:
38
+ summary = graph_summary(material, max_edges=0)
39
+ graph_ready += 1
40
+ if summary["validation"]["state"] != "valid":
41
+ graph_invalid += 1
42
+ except ValueError:
43
+ graph_excluded += 1
44
+
45
+ scorecard = get_default_scorecard()
46
+ score_report = scorecard.report(store.materials)
47
+ simulation_targets = {
48
+ material.material_id: simulation_readiness(material) for material in store.materials
49
+ }
50
+ default_material_id = get_default_material_id()
51
+ manifest = get_demo_manifest()
52
+ ml_state = chgnet_state()
53
+ checks = [
54
+ {
55
+ "id": "fixture",
56
+ "status": "pass" if store.materials else "fail",
57
+ "detail": f"{len(store.materials)} normalized records",
58
+ },
59
+ {
60
+ "id": "graphs",
61
+ "status": "pass" if graph_ready and not graph_invalid else "fail",
62
+ "detail": (
63
+ f"{graph_ready} graph-ready; {graph_excluded} excluded; {graph_invalid} invalid"
64
+ ),
65
+ },
66
+ {
67
+ "id": "ranking",
68
+ "status": "pass" if score_report["ranked_count"] >= 3 else "warn",
69
+ "detail": (
70
+ f"{score_report['ranked_count']} rank-eligible; "
71
+ f"{score_report['excluded_by_constraints']} excluded"
72
+ ),
73
+ },
74
+ {
75
+ "id": "ml_reference",
76
+ "status": "pass" if ml_state["reference_available"] else "warn",
77
+ "detail": str(ml_state["detail"]),
78
+ },
79
+ ]
80
+ overall = "ready" if all(check["status"] == "pass" for check in checks) else "degraded"
81
+ return {
82
+ "status": overall,
83
+ "fixture": {
84
+ "path": FIXTURE_RELATIVE_PATH,
85
+ "kind": "checksummed_real_snapshot",
86
+ "disclaimer": FIXTURE_DISCLAIMER,
87
+ "dataset": manifest["dataset"],
88
+ "subset": manifest["subset"],
89
+ "upstream_revision": manifest["upstream_revision"],
90
+ "hull_dataset": manifest["hull_dataset"],
91
+ "hull_revision": manifest["hull_revision"],
92
+ "license": manifest["license"],
93
+ "citation_doi": manifest["citation_doi"],
94
+ "snapshot_sha256": manifest["snapshot_sha256"],
95
+ "source_population": manifest["source_population"],
96
+ "field_sources": manifest["field_sources"],
97
+ },
98
+ "record_count": len(store.materials),
99
+ "graph": {
100
+ "included_count": graph_ready,
101
+ "excluded_count": graph_excluded,
102
+ "invalid_count": graph_invalid,
103
+ "validation_state": "valid" if graph_ready and not graph_invalid else "invalid",
104
+ },
105
+ "ranking": {
106
+ "ranked_count": score_report["ranked_count"],
107
+ "excluded_by_constraints": score_report["excluded_by_constraints"],
108
+ "binary_normalization": score_report["binary_normalization"],
109
+ "objectives": DEFAULT_OBJECTIVES,
110
+ "constraints": DEFAULT_CONSTRAINTS,
111
+ },
112
+ "default_material_id": default_material_id,
113
+ "chgnet": ml_state,
114
+ "simulation_targets": simulation_targets,
115
+ "checks": checks,
116
+ }
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from mattergraph import Material
5
+
6
+ from mattergraph_api.services import store_service
7
+ from mattergraph_api.services.demo_service import graph_summary
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/materials")
13
+ def list_materials(dataset_id: str | None = Query(default=None)) -> list[dict]:
14
+ store = store_service.resolve_store(dataset_id)
15
+ return [m.model_dump() for m in store.materials]
16
+
17
+
18
+ @router.get("/materials/{mid}")
19
+ def get_material(mid: str, dataset_id: str | None = Query(default=None)) -> dict:
20
+ store = store_service.resolve_store(dataset_id)
21
+ m: Material | None = store.get(mid)
22
+ if m is None:
23
+ raise HTTPException(status_code=404, detail="not found")
24
+ return m.model_dump()
25
+
26
+
27
+ @router.get("/materials/{mid}/graph-summary")
28
+ def get_material_graph_summary(
29
+ mid: str, dataset_id: str | None = Query(default=None)
30
+ ) -> dict:
31
+ store = store_service.resolve_store(dataset_id)
32
+ material: Material | None = store.get(mid)
33
+ if material is None:
34
+ raise HTTPException(status_code=404, detail="not found")
35
+ try:
36
+ return graph_summary(material)
37
+ except ValueError as exc:
38
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from fastapi import APIRouter
6
+ from mattergraph import Scorecard
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from mattergraph_api.services import store_service
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ Direction = Literal["minimize", "maximize"]
15
+ MissingPolicy = Literal["worst", "neutral", "exclude"]
16
+
17
+
18
+ class ObjectiveConfig(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ direction: Direction = "maximize"
22
+ weight: float = Field(default=1.0, ge=0.0)
23
+
24
+
25
+ class ConstraintConfig(BaseModel):
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ min: float | None = None
29
+ max: float | None = None
30
+ equals: bool | float | str | None = None
31
+
32
+
33
+ class ScoreRequest(BaseModel):
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ objectives: dict[str, Direction | ObjectiveConfig] = Field(
37
+ default_factory=dict,
38
+ )
39
+ constraints: dict[str, ConstraintConfig] = Field(default_factory=dict)
40
+ weights: dict[str, float] | None = None
41
+ missing: MissingPolicy = "worst"
42
+ dataset_id: str | None = None
43
+
44
+
45
+ @router.post("/scores/rank")
46
+ def rank(request: ScoreRequest) -> list[dict]:
47
+ store = store_service.resolve_store(request.dataset_id)
48
+ sc = _scorecard(request)
49
+ df = sc.rank(store.materials)
50
+ return df.to_dict(orient="records")
51
+
52
+
53
+ @router.post("/scores/rank/audit")
54
+ def rank_with_audit(request: ScoreRequest) -> dict[str, Any]:
55
+ store = store_service.resolve_store(request.dataset_id)
56
+ scorecard = _scorecard(request)
57
+ ranked = scorecard.rank(store.materials).to_dict(orient="records")
58
+ materials_by_id = {material.material_id: material for material in store.materials}
59
+ for row in ranked:
60
+ material = materials_by_id.get(str(row.get("material_id")))
61
+ if material is None:
62
+ continue
63
+ for property_name in request.constraints:
64
+ row.setdefault(property_name, material.get_numeric(property_name))
65
+ return {
66
+ "ranked": ranked,
67
+ "report": scorecard.report(store.materials),
68
+ "request": request.model_dump(mode="json"),
69
+ }
70
+
71
+
72
+ def _scorecard(request: ScoreRequest) -> Scorecard:
73
+ payload = request.model_dump(mode="python", exclude_none=True)
74
+ payload.pop("dataset_id", None)
75
+ return Scorecard(
76
+ objectives=payload["objectives"],
77
+ constraints=payload["constraints"],
78
+ weights=payload.get("weights"),
79
+ missing=payload["missing"],
80
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Query
4
+
5
+ from mattergraph_api.services import store_service
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/search")
11
+ def search(
12
+ element: str | None = Query(
13
+ default=None, description="Filter materials whose elements contain this symbol, e.g. Fe"
14
+ ),
15
+ dataset_id: str | None = Query(default=None),
16
+ ) -> list[dict]:
17
+ store = store_service.resolve_store(dataset_id)
18
+ out = []
19
+ for m in store.materials:
20
+ if not element:
21
+ out.append(m.model_dump())
22
+ continue
23
+ e = element.strip()
24
+ if e in m.elements:
25
+ out.append(m.model_dump())
26
+ return out
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from mattergraph_sim.job_spec import AseJobSpec, SimulationJob
5
+ from pydantic import BaseModel, Field
6
+
7
+ from mattergraph_api.services import store_service
8
+ from mattergraph_api.services.demo_service import get_chgnet_reference_artifact
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/simulations/chgnet/reference/{material_id}")
14
+ def get_chgnet_reference(material_id: str) -> dict:
15
+ artifact = get_chgnet_reference_artifact()
16
+ if artifact is None:
17
+ raise HTTPException(status_code=503, detail="verified CHGNet reference is unavailable")
18
+ if artifact["material_id"] != material_id:
19
+ raise HTTPException(status_code=404, detail="no CHGNet reference for this material")
20
+ return artifact
21
+
22
+
23
+ class RelaxRequest(BaseModel):
24
+ material_id: str
25
+ dataset_id: str | None = None
26
+ spec: AseJobSpec = Field(default_factory=AseJobSpec)
27
+
28
+
29
+ @router.post("/simulations/ase/relax")
30
+ def run_relax(body: RelaxRequest) -> dict:
31
+ try:
32
+ from mattergraph_sim import ase_relax
33
+ except ImportError as e:
34
+ raise HTTPException(status_code=503, detail=str(e)) from e
35
+
36
+ store = store_service.resolve_store(body.dataset_id)
37
+ m = store.get(body.material_id)
38
+ if m is None or m.structure is None:
39
+ raise HTTPException(status_code=400, detail="material or structure missing")
40
+ st = m.structure
41
+ job = SimulationJob(
42
+ spec=body.spec,
43
+ input_structure=st.to_json_dict(),
44
+ )
45
+ out = ase_relax(job)
46
+ return out.model_dump()
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from mattergraph_api.services.workflow_service import (
6
+ LeMaterialDemoWorkflowResponse,
7
+ build_lematerial_demo_workflow,
8
+ )
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get(
14
+ "/workflows/lematerial/demo",
15
+ response_model=LeMaterialDemoWorkflowResponse,
16
+ )
17
+ def lematerial_demo_workflow() -> LeMaterialDemoWorkflowResponse:
18
+ return build_lematerial_demo_workflow()
@@ -0,0 +1 @@
1
+ """Service layer for the demo API."""