Serving the KG to Atlas¶
Once a program's graph is built, graffold-ingest deploy loads it into a served backend (FalkorDB) as a named graph per program. The Atlas CLI then queries it by kg_id through graffold-api's /v1/atlas/* endpoints — for target validation, cross-run memory, and evidence lookup.
flowchart LR
P[("Parquet graph<br/><small>etec-pigs-harmonized</small>")] -->|"deploy"| F[("FalkorDB<br/><small>named graph: etec</small>")]
A["Atlas CLI<br/><small>graffold_client</small>"] -->|"kg_id=etec"| API["graffold-api<br/><small>/v1/atlas/*</small>"]
API --> F
1. Deploy a graph¶
Load the latest harmonized Parquet snapshot into a named graph:
# One graph
graffold-ingest deploy ~/.graffold/parquet/etec-pigs-harmonized \
--backend falkordb --graph etec
# The cross-species master graph
graffold-ingest deploy ~/.graffold/parquet/master \
--backend falkordb --graph master
| Option | Default | Description |
|---|---|---|
GRAPH_DIR |
(required) | Parquet directory — reads the latest harmonized snapshot |
--backend |
falkordb |
falkordb | neo4j | neptune | spanner |
--graph |
dir name | Named graph = the kg_id Atlas queries |
--host / --port |
backend default | Target DB address |
Each program becomes an isolated named graph (etec, alltech, elanco, zoetis, master) in one FalkorDB instance — per-program serving with no cross-contamination.
2. Start the API¶
Authentication¶
Every /v1/atlas/* and /v1/kg/* route is gated by require_api_access — the caller must be authenticated and on a tier with API access (Pro, Max, or Enterprise; Free is 403'd). All requests use a Bearer token in the Authorization header. Three token types are accepted:
For the Atlas CLI hitting the API unattended. Set a shared token on the server as the API_AUTH_TOKEN env var; any request presenting it authenticates as Enterprise tier.
# On the server (or in the compose/env for graffold-api)
export API_AUTH_TOKEN="$(openssl rand -hex 32)"
# On the Atlas side
export GRAFFOLD_API_URL="https://api.graffold.com"
export GRAFFOLD_API_KEY="<the same API_AUTH_TOKEN value>"
This is the simplest path for the POC — one shared secret, no per-user JWT.
Paying users get a signed JWT (issued by the billing webhook) carrying their tier. The token is what the webapp stores in localStorage and sends as the Bearer token. Reuse it for API calls:
Tier is read from the JWT claims; Pro/Max/Enterprise pass the API gate.
For browser / interactive use, Cloudflare Access authenticates by email OTP and sets the Cf-Access-Authenticated-User-Email header. Tier is resolved from TIER_OVERRIDES. This path is for humans in the webapp, not the Atlas CLI — machine-to-machine should use a service token behind a CF Access service token (client-id / client-secret headers) so the tunnel lets the request through.
Free tier is blocked
/v1/atlas/* requires allow_api_access=true in the tier manifest. A Free-tier token returns 403 Upgrade to Pro to access the API. Use a service token (Enterprise) or a Pro+ JWT.
Every example below sends the token as Authorization: Bearer $GRAFFOLD_API_KEY.
Or use the graffold-ingest CLI directly
You don't have to hand-write curl. The graffold-ingest kg command group queries these same hosted graphs with a stored key — CLI/webapp parity:
graffold-ingest login <api-key> # get a key from Admin → API & Query
graffold-ingest kg graphs # list hosted graphs
graffold-ingest kg coverage "FedF,FaeG,LT" --graph etec
graffold-ingest kg kills --graph etec # what was killed and why
graffold-ingest kg prior-knowledge "post-weaning ETEC" --graph etec
3. Query from the Atlas CLI¶
The Atlas CLI's graffold_client hits these endpoints, each scoped to a kg_id (the FalkorDB graph name). All examples assume GRAFFOLD_API_URL points at the server and GRAFFOLD_API_KEY holds a valid Bearer token (see Authentication).
Validate targets¶
Check which proposed targets exist in the KG with supporting evidence:
curl -X POST "$GRAFFOLD_API_URL/v1/atlas/validate-targets" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kg_id": "etec",
"targets": [
{"name": "FedF"},
{"name": "FaeG"},
{"name": "madeup-target-xyz"}
]
}'
Returns {validated: [...], rejected: [...], predicted_novel: []}. Validated targets carry a support_degree and heuristic score; rejected ones carry a reason (not found in KG).
Evidence for a claim¶
Check a subject–predicate–object claim against the graph, with contradiction flagging:
curl -G "$GRAFFOLD_API_URL/v1/atlas/evidence-for-claim" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
--data-urlencode "kg_id=etec" \
--data-urlencode "subject=Auranofin" \
--data-urlencode "predicate=INHIBITS" \
--data-urlencode "object=CpTrxR"
Returns {claim, evidence: [...], contradiction_flag} — evidence neighbors with source doc IDs.
Contradictions (contested claims)¶
Surface entity pairs where different sources assert opposing relations — a contested-claim risk flag a single model call cannot produce. FLAGS for triage; does not adjudicate:
curl -G "$GRAFFOLD_API_URL/v1/kg/contradictions" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
--data-urlencode "kg_id=etec" \
--data-urlencode "min_side=2"
Returns {kg_id, contradictions: [...]} — each entry a pair with opposing relation types and per-side distinct-source counts. min_side (default 2) requires at least that many distinct papers on each side, so same-paper extraction noise is filtered out. Paid-tier gated; /v1/atlas/contradictions is an inherited alias.
Entity coverage¶
How many of a list of entities exist in the KG:
curl -G "$GRAFFOLD_API_URL/v1/atlas/entity-coverage" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
--data-urlencode "kg_id=etec" \
--data-urlencode "entities=FedF,FaeG,LT,ST,GM1"
Returns {total, found, coverage, missing}.
Record a decision (cross-run memory)¶
Write an advance/kill decision back into the KG so the next run remembers it:
curl -X POST "$GRAFFOLD_API_URL/v1/atlas/record-decision?kg_id=etec" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"run_id": "phibro-etec-v3",
"stage": "reaper",
"query": "Kill carvacrol?",
"outcome": "KILLED — no pellet stability, prior field failure",
"steps": []
}'
Returns {trace_id}.
Similar prior decisions¶
Find prior decisions relevant to the current question — the feedback loop that stops re-proposing dead ends:
curl -G "$GRAFFOLD_API_URL/v1/atlas/similar-decisions" \
-H "Authorization: Bearer $GRAFFOLD_API_KEY" \
--data-urlencode "kg_id=etec" \
--data-urlencode "query=essential oil blend for gut health" \
--data-urlencode "limit=5"
Returns {decisions: [...]} — prior kills/advances matched by text overlap.
Endpoint reference¶
| Endpoint | Method | Purpose |
|---|---|---|
/v1/atlas/validate-targets |
POST | Presence + support check for proposed targets |
/v1/atlas/evidence-for-claim |
GET | S-P-O evidence + contradiction flag |
/v1/kg/contradictions |
GET | Contested-claim pairs (opposing relations) with per-side source counts |
/v1/atlas/entity-coverage |
GET | How many entities are in the KG |
/v1/atlas/record-decision |
POST | Write a Decision node (cross-run memory) |
/v1/atlas/similar-decisions |
GET | Retrieve prior decisions by relevance |
/v1/atlas/infer-network |
POST | Network inference (sparselink/GNN — reserved, returns 501) |
All endpoints take a kg_id (query param or body field) that maps to the FalkorDB graph name — so kg_id=etec queries the Phibro ETEC graph, kg_id=master queries the cross-species merge.
Backend flexibility
FalkorDB is the current POC serving backend (native multi-graph — one named graph per program). The same deploy command targets Neo4j, Neptune, or Spanner; graffold-api routes per-graph the same way regardless of backend.
Integration in the Atlas pipeline¶
# atlas/native/graffold_client.py (sketch)
import httpx, os
class GraffoldClient:
def __init__(self, kg_id: str):
self.base = os.environ["GRAFFOLD_API_URL"]
self.kg_id = kg_id
self.headers = {"Authorization": f"Bearer {os.environ['GRAFFOLD_API_KEY']}"}
async def validate_targets(self, targets: list[dict]) -> dict:
r = await httpx.AsyncClient().post(
f"{self.base}/v1/atlas/validate-targets",
headers=self.headers,
json={"kg_id": self.kg_id, "targets": targets},
)
return r.json()
async def similar_decisions(self, query: str) -> dict:
r = await httpx.AsyncClient().get(
f"{self.base}/v1/atlas/similar-decisions",
headers=self.headers,
params={"kg_id": self.kg_id, "query": query},
)
return r.json()
An Atlas stage calls validate_targets() before advancing candidates, similar_decisions() to avoid known dead ends, and record_decision() after each advance/kill — so every run both reads from and writes to the accumulated graph.