Skip to content

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

graffold-ingest serve --port 8001
# graffold-api exposes /v1/atlas/* backed by the FalkorDB graphs

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:

export GRAFFOLD_API_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVC..."

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
See the Ingest API reference for the full command list.


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.