AI Agent Instructions: GraFlag Method Integration
This document provides precise instructions for an AI agent to integrate new graph anomaly detection methods into the GraFlag benchmarking framework.
An agent that supports skills can use the Method Integration Skill instead: the same procedure, packaged with its four gates and a result checker, in graflag-shared.
Quick Reference
Item |
Location |
|---|---|
Methods directory |
|
Datasets directory |
|
Libraries directory |
|
Start from |
|
Entry point script |
|
Container entry point |
always |
CLI command |
|
Two Integration Patterns
Every method runs the same way – CMD ["python3", "-m", "graflag_runner"] –
and the two patterns differ only in what COMMAND in .env points at.
Pattern A: An integration script
COMMAND=python3 train_graflag.py
The script declares a Config dataclass and fills it from the _-prefixed
env vars with one call:
from dataclasses import dataclass
from graflag_runner import params
@dataclass
class Config:
batch_size: int = 128
learning_rate: float = 0.001
use_memory: bool = True
config = Config(**params(Config)) # _BATCH_SIZE=256 -> config.batch_size == 256 (an int)
params() strips the underscore, lowercases the name, coerces to the
annotated type, and leaves the dataclass default in place for anything the
.env does not set. Every method with a script uses this pattern – all
sixteen outside the PyGOD family: ada_gad, addgraph, ad_gcl, anograph,
diffgad, dynwalk, f_fade, gady, generaldyg, huge_gad, midas,
rare, slade, streamspot, strgnn and taddy, plus the example
template. Fourteen build their Config with params() as above; generaldyg
and ada_gad adopt upstream’s argparse namespace with apply_params() instead
(see File 3).
Pattern B: A library entry point
COMMAND=python3 -m graflag_bond.train
graflag_bond selects the PyGOD detector from METHOD_NAME and builds its
constructor arguments from the same env vars, dropping any the detector does
not accept. Used by all 17 bond_* methods – which is what lets them share
one image (IMAGE=bond_base) and still run different detectors.
--pass-env-args: supported, and used by nothing
CMD ["python3", "-m", "graflag_runner", "--pass-env-args"]
The runner rewrites _BATCH_SIZE=128 into --batch_size 128 and appends it
to COMMAND. Every method that once used this was migrated to params():
the rewrite lowercases names (a repo whose flag is --lr_G needs it renamed),
coerces nothing, supplies no defaults, and offers no way to accept a parameter
while recording that it does nothing. Reach for it only when COMMAND is
upstream’s own entry point and you are not writing a script at all.
Integration Checklist
For each method, create/verify the following files:
graflag-shared/methods/{method_name}/
├── .env # REQUIRED: Configuration
├── Dockerfile # REQUIRED unless .env sets IMAGE=<shared image>
├── train_graflag.py # REQUIRED unless COMMAND is a library entry point
├── patches/*.patch # OPTIONAL: fixes to the clone, via `git apply`
└── requirements.txt # OPTIONAL: Python dependencies
The upstream clone is not a directory here. It is created inside the image
at build time (git clone ... src in /app) from SOURCE_CODE at
SOURCE_REF, so it never enters the build context and never gets committed.
File 1: .env Configuration
Rules
METHOD_NAME: Lowercase, alphanumeric and underscores onlyCOMMAND: Entry point command (e.g.,python3 train_graflag.pyorpython3 -m graflag_bond.train)SUPPORTED_DATASETS: Comma-separated list of compatible dataset names (supports wildcards likebond_*)Hyperparameters: ALL must be prefixed with
_(underscore)Parameter naming:
params()strips the underscore and lowercases the rest_BATCH_SIZE=128becomesbatch_size=128_LR_G=0.001becomeslr_g=0.001, so the field in yourConfigislr_g, notlr_G
Boolean parameters: spell the value out –
_USE_MEMORY=trueor_USE_MEMORY=falseparams()readstrue/false/1/0/yes/no, case-insensitivelyNEVER write a bare
_USE_MEMORY=. Under--pass-env-argsan empty value became a bare--use_memoryflag that argparse read as True;params()reads the same empty string as False. A.envcarrying the old spelling silently inverts the flag, which is howgadyran with memory disabled.
Reserved variables (set by the orchestrator; a parameter with one of these names is silently dropped):
DATA,EXP,METHOD_NAME,COMMAND,MONITOR_INTERVALSOURCE_REF: the 40-character commit your Dockerfile checks out. Required whenever the image clones anything. An unpinned clone is a method whose numbers change when upstream does, with nothing in the experiment saying so – and the failure arrives as aTypeErrorabout a signature, which reads like a GraFlag bug.INTEGRATION:upstreamorreimplementation.SOURCE_CODEnames the paper’s repository either way; this says whether that is the code the image actually runs. Writereimplementationif you wrote the model yourself, even faithfully.IMAGE(optional): the name of a shared image underimages/<name>/, used instead of a Dockerfile in your method directory. Declare it only when your image would be byte-identical to an existing one – the seventeenbond_*methods sharebond_base. Do not copy a Dockerfile that already exists; the test suite fails on duplicates and names the file you duplicated.
graflag-shared/tests/test_methods.py checks every one of these. It is faster to read the failure than to discover the same thing on the cluster.
Template (integration script)
METHOD_NAME={method_name}
DESCRIPTION={Brief description from paper}
SOURCE_CODE={GitHub URL}
SOURCE_REF={40-char commit sha, from `git ls-remote {GitHub URL} HEAD`}
INTEGRATION=upstream
SUPPORTED_DATASETS={dataset1},{dataset2}
COMMAND=python3 train_graflag.py
# === HYPERPARAMETERS ===
_EPOCHS=100
_BATCH_SIZE=128
_LEARNING_RATE=0.001
_HIDDEN_DIM=64
_SEED=42
Real Example: TADDY
Copied from methods/taddy/.env, comments stripped:
METHOD_NAME=taddy
DESCRIPTION=Temporal Anomaly Detection in Dynamic Networks via Transformer
SOURCE_CODE=https://github.com/yuetan031/TADDY_pytorch
SOURCE_REF=dfe15ddbbc179ed0ca9a96482fe1696dfbcb5bcd
INTEGRATION=upstream
SUPPORTED_DATASETS=uci,btc_alpha,btc_otc,digg
COMMAND=python3 train_graflag.py
_ANOMALY_PER=0.1
_TRAIN_PER=0.5
_NEIGHBOR_NUM=5
_WINDOW_SIZE=2
_EMBEDDING_DIM=32
_NUM_HIDDEN_LAYERS=2
_NUM_ATTENTION_HEADS=2
_MAX_EPOCH=200
_LEARNING_RATE=0.001
_WEIGHT_DECAY=0.0005
_SEED=1
_PRINT_FREQ=10
_GPU=0
File 2: Dockerfile
Template (integration script)
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
# System dependencies
RUN apt-get update && apt-get install -y \
python3 python3-pip git \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
# PyTorch (adjust version based on method requirements)
RUN pip install --no-cache-dir \
torch torchvision torchaudio \
--index-url https://download.pytorch.org/whl/cu121
# Common dependencies
RUN pip install --no-cache-dir numpy scipy scikit-learn pandas networkx tqdm
# PyTorch Geometric (if needed)
# RUN pip install --no-cache-dir torch-geometric
# RUN pip install --no-cache-dir \
# torch-scatter torch-sparse \
# -f https://data.pyg.org/whl/torch-2.1.0+cu121.html
# Clone the pinned upstream. SOURCE_CODE and SOURCE_REF come from this
# method's .env; graflag forwards them as --build-arg (BUILD_ARG_KEYS in
# docker_ops.py), so the repository and the commit are named in one place.
#
# `test -n` is not ceremony: with SOURCE_REF empty, `git checkout --detach`
# still exits 0 and leaves the clone on whatever the default branch holds
# that day -- exactly the silent float the pin exists to remove.
ARG SOURCE_CODE
ARG SOURCE_REF
RUN test -n "${SOURCE_CODE}" && test -n "${SOURCE_REF}" \
&& git clone ${SOURCE_CODE} src \
&& git -C src checkout --detach ${SOURCE_REF}
# Copy GraFlag integration files
COPY methods/{method_name}/train_graflag.py ./
COPY methods/{method_name}/*.py ./
# Where the GraFlag libraries come from: `local` (the default) installs the
# copy on the share, `pypi` the published wheel. graflag passes the choice
# as --build-arg from GRAFLAG_LIBS in its config. Copy this stanza verbatim.
#
# The unknown arm exits non-zero on purpose. An if/else would read a typo as
# "pypi" and build an image whose libraries are not the ones that were asked
# for -- and the build would report success.
ARG GRAFLAG_LIBS=local
COPY libs/graflag_runner /tmp/graflag_libs/graflag_runner
RUN case "${GRAFLAG_LIBS}" in \
local) pip install --no-cache-dir /tmp/graflag_libs/graflag_runner ;; \
pypi) pip install --no-cache-dir graflag-runner ;; \
*) echo "[ERROR] GRAFLAG_LIBS must be local or pypi, got '${GRAFLAG_LIBS}'" >&2; exit 1 ;; \
esac && rm -rf /tmp/graflag_libs
# The entry point is always the runner. It sets DATA/EXP, starts the resource
# monitor, runs COMMAND from .env, and records the status -- your script is
# what COMMAND names, not what CMD names.
CMD ["python3", "-m", "graflag_runner"]
Key Points
Build context: The entire
graflag-shared/directory is the build context, soCOPY methods/<name>/...paths work. Notedatasets/,experiments/and.git/are excluded by.dockerignore– datasets are bind-mounted at run time, never built into the image. That file has to be on the share to do anything, since the context root isSHARED_DIRon the manager and not your checkout; neithersyncnorsync --libputs it there, so send it once withgraflag copy -s ./.dockerignore --dest .. A build with no.dockerignoreat the context root succeeds at full size rather than failing, which is whygraflag run --buildwarns about it. Docker’s own accounting on this cluster, for the same share:Sending build context to Docker daemon 3.651GBwithout the file,804.4kBwith it.Installing the GraFlag libraries: copy the
ARG GRAFLAG_LIBSstanza above; do not write your own install line. The choice is the cluster’s, not the method’s –GRAFLAG_LIBSin~/.config/graflag/config.envselects it for every build, and a method that hardcodespip install graflag-runnersilently opts out of a library change the operator asked for. (graflag/tests/test_local_libs.pyasserts a method.envcannot override it.)This is also why
graflag sync --libalone is not enough: it updates/shared/libs, but nothing inside an already-built image. Re-run the method with--buildafterwards, which is what picks the new copy up.Base image: Use the base the method’s requirements call for. Most methods use an
nvidia/cudaruntime image, from11.1.1-runtime-ubuntu20.04for older code to13.0.2-runtime-ubuntu24.04; CPU-only methods useubuntuorpythonimages;rareusespython:3.12.4-slimwith pip-installed CUDA wheels, to reproduce the environment it was developed in. A base without CUDA must setNVIDIA_VISIBLE_DEVICES=allandNVIDIA_DRIVER_CAPABILITIES=compute,utility, or the nvidia runtime hands the container no GPU.graflag_runner: Always install it – it handles execution lifecycle, resource monitoring, and status tracking.
File 3: train_graflag.py
Needed whenever COMMAND names a script. A method whose COMMAND is a
library entry point (python3 -m graflag_bond.train) has no such file.
Read methods/example/train_graflag.py before writing one. It is the
annotated template, it is 172 lines, and every helper below is used in it.
Critical Implementation Details
1. Parameters: one dataclass, one call
from dataclasses import dataclass
from graflag_runner import params
@dataclass
class Config:
"""The parameter contract. Every field is a `_FOO` in .env, and the
annotation is what the value is coerced to."""
learning_rate: float = 0.001
max_epoch: int = 200
batch_size: int = 128
use_memory: bool = True
seed: int = 42
gpu: int = 0
config = Config(**params(Config))
# _LEARNING_RATE=0.001 -> config.learning_rate == 0.001 (a float, not "0.001")
# _USE_MEMORY=false -> config.use_memory is False
# a _FOO with no field -> dropped, with a [WARN] naming it
params() strips the underscore, lowercases the name, coerces against the
annotation, and drops what the signature does not accept. The dataclass
default is what applies when .env does not set the key, so a run never
depends on a key being present.
What this replaces, and why none of it should come back:
Old idiom |
Why it is gone |
|---|---|
|
|
|
there is no argv, so there is no case to alias – the field is |
a 30-key |
the dataclass is the mapping |
|
a string |
If the method has its own argparse and you want upstream’s defaults, do not re-declare them. Take upstream’s namespace and write onto it:
from option import args # upstream's own parser, with its defaults
from graflag_runner import apply_params
applied = apply_params(args, ignore={"gpu"}) # _GPU is read by device()
Each _FOO is matched to an existing attribute and coerced to the type of the
value already there, so upstream’s defaults define both what is accepted and
how it is read. generaldyg and ada_gad work this way.
This is also the safer half of the argument for not using --pass-env-args:
passing parameters through argv gives argparse’s abbreviation matching a say.
With upstream declaring --gpus and no --gpu, _GPU=0 arrives as
--gpu 0 and argparse quietly sets gpus=0.
2. Everything else the SDK provides
from graflag_runner import (
device, load_dataset, params, paths, seed_all, upstream,
)
run = paths() # run.data, run.exp, run.dataset -- raises if unset
seed_all(config.seed) # random, numpy and torch, together
dev = device() # honours _GPU=-1 (which is what `--no-gpu` sets)
upstream("src") # puts the cloned repo on sys.path, raises if absent
edges, labels = load_dataset() # (edges, labels) from any of the four layouts
Use them rather than hand-rolling the equivalent. Each one exists because the hand-rolled version was wrong somewhere:
device()– three methods builtf"cuda:{gpu}"themselves and died onInvalid device string: 'cuda:-1'. One place understands that-1is CPU.paths()– a missingEXPused to surface as aTypeErrorinsideos.path.join, hundreds of lines later.upstream()– anchors on__file__, so it does not depend on the working directory, and it raises when the clone is missing instead of failing at the first import.load_dataset()– the same 100-line snapshot loader had been copied into five methods, transposes and all.seed_all()– before it, no method seeded numpy or torch, so no run was repeatable.
Do not time main(), sample psutil memory, or read
torch.cuda.max_memory_allocated(). graflag_runner measures all three from
outside the method, over the whole process tree, and its numbers are the
ones reported; anything you record yourself is kept beside them as
method_reported_<key>. A method that sampled its own peak memory reported
409 MB against the monitor’s 907 MB.
3. Environment Variables
The orchestrator sets these environment variables in every container:
Variable |
Description |
Example |
|---|---|---|
|
Input dataset directory |
|
|
Experiment output directory |
|
|
Method identifier |
|
|
Command from .env |
|
|
Which |
|
4. ResultWriter API
from graflag_runner import ResultWriter
writer = ResultWriter() # Auto-reads EXP env var
# Add metadata (call before or after save_scores). The summary says which split
# the scores cover and how many there are -- gate 1 checks -- and carries the
# method's own AUC over them, which `graflag verify` compares with the evaluator's.
writer.add_metadata(method_name="taddy", dataset="uci", learning_rate=0.001,
summary={"dataset_info": {"scored_split": "test",
"scored_samples": 1200},
"results": {"test_auc": 0.93}})
# Resource metrics: rarely needed, and not what gets reported. The runner
# measures exec time, peak memory and peak GPU from outside the method and
# merges its own numbers in afterwards; whatever you pass here is preserved
# beside them as method_reported_exec_time_ms and so on.
writer.add_resource_metrics(exec_time_ms=45230.15, peak_memory_mb=2048.5)
# Track training progress (creates training.csv)
writer.spot("training", epoch=1, loss=0.5, auc=0.85)
writer.spot("training", epoch=2, loss=0.3, auc=0.90)
# Track validation metrics (creates validation.csv)
writer.spot("validation", val_loss=0.4, val_auc=0.88)
# Save final scores
writer.save_scores(
result_type="EDGE_STREAM_ANOMALY_SCORES",
scores=scores_list,
ground_truth=labels_list,
)
# Finalize (writes results.json)
writer.finalize()
For large results, use streaming to avoid memory issues:
from graflag_runner import ResultWriter, StreamableArray
writer.save_scores(
result_type="NODE_ANOMALY_SCORES",
scores=StreamableArray(score_generator()), # Writes row-by-row
ground_truth=test_labels, # still required for evaluation
)
5. Result Saving (CRITICAL)
# CRITICAL RULES:
# 1. Use TEST data for evaluation (contains anomalies)
# 2. Training data typically has all zeros (no anomalies)
# 3. Always include ground_truth
# 4. Choose correct result_type
writer.save_scores(
result_type=result_type,
scores=scores if isinstance(scores, list) else scores.tolist(),
ground_truth=labels if isinstance(labels, list) else labels.tolist(),
)
Complete Template
This is methods/example/train_graflag.py in outline. Copy the directory
rather than this block – the real file is annotated and stays current.
"""
GraFlag integration for {MethodName}.
{Description}
Source: {GitHub URL} at {SOURCE_REF}
"""
from dataclasses import asdict, dataclass
import numpy as np
from sklearn.metrics import roc_auc_score
from graflag_runner import (
ResultWriter, device, info, load_dataset, params, paths, seed_all, upstream,
)
# Put the clone on sys.path before importing anything from it. upstream()
# anchors on __file__ and raises if the clone is missing, rather than letting
# the first import fail somewhere less obvious.
upstream("src")
# from your_module import YourModel, YourDataLoader
@dataclass
class Config:
"""The parameter contract: one field per `_FOO` in .env.
The annotation is what the value is coerced to, and the default is what
applies when .env does not set the key -- so the run never depends on a
key being present, and a stale .env key cannot reach the model as a
surprise keyword argument.
"""
# Model
hidden_dim: int = 64
num_layers: int = 2
# Training
batch_size: int = 128
epochs: int = 100
learning_rate: float = 0.001
use_feature: bool = False # _USE_FEATURE=true / false, spelled out
# Run
seed: int = 42
gpu: int = 0 # read by device(), not by the method
def main():
config = Config(**params(Config))
run = paths() # run.data, run.exp, run.dataset
seed_all(config.seed)
dev = device() # cpu when _GPU=-1
info(f"[INFO] {run.dataset} from {run.data} on {dev}")
for key, value in asdict(config).items():
info(f"[INFO] {key}: {value}")
edges, labels = load_dataset() # or the method's own loader, from the clone
writer = ResultWriter() # reads EXP itself
# --- train -------------------------------------------------------------
# model = YourModel(**asdict(config)).to(dev)
# for epoch in range(1, config.epochs + 1):
# loss = train_epoch(model, edges)
# writer.spot("training", epoch=epoch, loss=loss)
# --- score the TEST split ----------------------------------------------
# The training split usually has no anomalies at all, and an evaluation
# over one class produces a null AUC rather than an error.
scores = np.zeros(len(edges)) # replace
ground_truth = labels # replace
test_auc = float(roc_auc_score(ground_truth, scores)) # the method's own number
writer.save_scores(
result_type="EDGE_STREAM_ANOMALY_SCORES", # adjust per method
scores=scores.tolist(),
ground_truth=list(ground_truth),
)
# Which split the scores cover and how many there are (gate 1 checks), and
# the method's own AUC over exactly these scores (gate 4 compares).
writer.add_metadata(dataset=run.dataset, **asdict(config), summary={
"dataset_info": {"scored_split": "test", "scored_samples": len(scores)},
"results": {"test_auc": test_auc}, # replace
})
writer.finalize() # atomic; writes results.json
if __name__ == "__main__":
main()
What is deliberately absent, and should stay absent: argparse, str2bool,
os.environ lookups, a time.time() around main(), a psutil sampler, a
try/except that logs and re-raises (the runner already records the traceback
and the failed status), and logging.basicConfig (info/warning/error
from graflag_runner write in the house format).
File 4: Dataset Directory (only for a new dataset)
Data files are never committed. A dataset directory holds a metadata.json
saying where its files come from, and graflag-data downloads them onto the
share; graflag run does it on demand before deploying.
Structure
graflag-shared/datasets/{dataset_name}/
└── metadata.json # the descriptor; the files are fetched next to it
metadata.json
{
"name": "bond_inj_cora",
"description": "Cora citation graph with injected outliers (PyGOD BOND benchmark).",
"source": "https://github.com/pygod-team/pygod",
"source_repo": "https://github.com/pygod-team/data",
"compatible_methods": ["bond_*"],
"format": "PyTorch-Geometric Data object (.pt)",
"files": [
{
"name": "bond_inj_cora.pt",
"url": "https://github.com/pygod-team/data/raw/main/inj_cora.pt.zip",
"extract": "zip",
"members": ["inj_cora.pt"]
}
]
}
Each entry of files names the file to produce and the URL to download, and,
for an archive, how to extract it (extract) and which member to keep
(members). A sha256 lets graflag-data verify check the file. A dataset
built from another one sets derived: true, derived_from and a build
command instead of URLs. libs/graflag_data/README.md has the full schema.
Rules
Naming: lowercase and descriptive (
uci,btc_alpha,bond_inj_cora);-d <name>refers todatasets/<name>/Never commit data files: only
metadata.jsonis versionedFetch and check before the first run:
graflag-data fetch {dataset_name}, thengraflag-data status
Examples
Existing dataset names in the platform:
bond_inj_cora,bond_inj_amazon,bond_inj_flickr(injection-based anomaly)bond_books,bond_disney,bond_enron,bond_reddit,bond_weibo(real-world)bond_gen_100,bond_gen_500,bond_gen_1000,bond_gen_5000,bond_gen_10000(synthetic)btc_alpha,btc_otc(cryptocurrency networks)uci(social network)
Result Types Reference
Method Output |
Result Type |
scores Format |
|---|---|---|
Static node scores |
|
|
Static edge scores |
|
|
Static graph scores |
|
|
Dynamic node (snapshots) |
|
|
Dynamic edge (snapshots) |
|
|
Dynamic graph (snapshots) |
|
|
Streaming nodes |
|
|
Streaming edges |
|
|
Streaming graphs |
|
|
Special score values:
-1: Unknown/unassigned-2: Inactive/unseen at this time step
Common Errors and Fixes
Error 1: RuntimeError: Invalid device string: 'cuda:-1'
Cause: the script builds f"cuda:{config.gpu}" itself. graflag run --no-gpu sets _GPU=-1, and -1 means CPU.
Fix: take the device from the SDK, which is the one place that knows:
from graflag_runner import device
dev = device() # torch.device("cpu") when _GPU=-1
Error 2: a --params sweep produces identical runs
Cause: the parameter reaches nothing. params() drops a _FOO your
Config has no field for, and .env keys outlive the code that read them –
gady accepted _LR for a model that trains two optimizers on _LR_D and
_LR_G, and upstream never read it either.
Fix: if the parameter is genuinely inert, keep the key and say so out loud, rather than deleting it and leaving the sweep looking successful:
INERT_PARAMS = {"lr": "GADY trains two optimizers, on _LR_D and _LR_G"}
for name, why in INERT_PARAMS.items():
if getattr(config, name) != getattr(Config, name):
warning(f"[WARN] _{name.upper()} is not used: {why}")
List it in the method’s README under “Parameters that do nothing”.
Error 3: a boolean is the opposite of what .env says
Cause: the old empty spelling, _USE_MEMORY=. Under --pass-env-args
that became a bare --use_memory flag, which argparse read as True.
params() reads the empty string as False.
Fix: spell every boolean out – _USE_MEMORY=true. Including the
commented-out ones, which are what the next person copies.
Error 4: “File not found” for a dataset on the cluster
Cause: the dataset’s files were never fetched onto the share. Until they
are, a dataset directory holds only its metadata.json.
Fix: on the manager, graflag-data status shows what is missing and
graflag-data fetch <name> downloads it. graflag run fetches automatically,
so this appears when a method is run by hand or a download failed.
Error 5: “AUC is null”
Cause: Using training data (all labels = 0, no anomalies)
Fix: Use TEST data that contains anomalies:
# WRONG: Using training snapshots
for snap in data['snap_train']:
scores.append(predict(snap))
# RIGHT: Using test snapshots with injected anomalies
for snap in data['snap_test']:
scores.append(predict(snap))
Error 6: “ValueError: setting array element with sequence”
Cause: Ragged arrays (different lengths per timestamp)
Status: Handled by graflag_evaluator (flattens ragged arrays automatically)
Testing Commands
# 1. Sync method to cluster
graflag sync --path methods/{method_name}
# 2. Build and run
graflag run -m {method_name} -d {dataset_name} --build
# 3. Check logs (follow in real-time)
graflag logs -e exp__{method_name}__{dataset_name}__TIMESTAMP -f
# 4. Stop if needed
graflag stop -e exp__{method_name}__{dataset_name}__TIMESTAMP
# 5. Evaluate results
graflag evaluate -e exp__{method_name}__{dataset_name}__TIMESTAMP
# 6. Run with custom parameters
graflag run -m {method_name} -d {dataset_name} --params EPOCHS=50 BATCH_SIZE=64
Agent Prompt Template
Use this prompt to instruct an AI agent to integrate methods:
# Task: Integrate Graph Anomaly Detection Methods into GraFlag
## Methods to Integrate
| Method | Paper | GitHub | Description |
|--------|-------|--------|-------------|
| {method1} | {paper1} | {github1} | {desc1} |
| {method2} | {paper2} | {github2} | {desc2} |
## Instructions
For EACH method in the list above, perform the following steps:
### Step 1: Analyze Original Repository
1. Examine the GitHub repository structure
2. Identify:
- Main training script and entry point
- Data loading code and expected data format
- Model architecture files
- All configurable hyperparameters (check argparse)
- Required Python dependencies
- CUDA/PyTorch version requirements
### Step 2: Choose Integration Pattern
- **An integration script** (`COMMAND=python3 train_graflag.py`): the default.
Write `train_graflag.py`, declare a `Config` dataclass, and build it with
`Config(**params(Config))`. Use this unless the next bullet applies.
- **A library entry point** (`COMMAND=python3 -m graflag_bond.train`): only when
a library already covers the method family, as `graflag_bond` does for the
PyGOD detectors. Then the method is a `.env` and nothing else.
### Step 3: Create Method Directory
Create `graflag-shared/methods/{method_name}/` with these files:
#### 3.1: `.env` File
- Set METHOD_NAME (lowercase, alphanumeric, underscores)
- Set DESCRIPTION from paper abstract
- Set SOURCE_CODE to GitHub URL
- Set SOURCE_REF to the 40-char commit the Dockerfile will check out (`git ls-remote <url> HEAD`)
- Set INTEGRATION to `upstream` or `reimplementation`
- Set SUPPORTED_DATASETS to compatible dataset names (comma-separated, wildcards ok)
- Set COMMAND (e.g., `python3 train_graflag.py` or `python3 -m graflag_bond.train`)
- Add ALL hyperparameters with `_` prefix
- For booleans, spell the value out: `_USE_MEMORY=true`, never a bare `_USE_MEMORY=`
- Remember: `params()` strips the underscore and lowercases, so `_LR_G` is the field `lr_g`
#### 3.2: `Dockerfile`
- Base: `nvidia/cuda` (version matching method requirements)
- Install all Python dependencies
- Clone the pinned source: `ARG SOURCE_CODE` / `ARG SOURCE_REF`, then `test -n` both, `git clone`, `git -C src checkout --detach ${SOURCE_REF}`
- Copy train_graflag.py and helper files
- Install the GraFlag libraries with the `ARG GRAFLAG_LIBS` / `case` stanza, copied verbatim -- not a bare `pip install graflag-runner`
- Add `graflag_bond` to the same `case` stanza if the method is a PyGOD detector
- CMD is always `["python3", "-m", "graflag_runner"]`; what differs is `COMMAND` in `.env`
#### 3.3: `train_graflag.py`
- Start from `methods/example/train_graflag.py`; do not write one from scratch
- Declare a `Config` dataclass whose fields are the method's parameters, with types
and defaults, and build it with `Config(**params(Config))`
- If the method has its own argparse you want the defaults from, import that
namespace and call `apply_params(ns, ignore={"gpu"})` instead
- Take the dataset from `load_dataset()` and the directories from `paths()`,
not from `os.environ` by hand
- Take the device from `device()`, and seed with `seed_all(config.seed)`
- Put the clone on the path with `upstream("src")`, then import the method's code
- Do NOT time the run or sample memory yourself -- see "Everything else the SDK provides"
- Call `writer.spot("training", ...)` during training loop
- Generate predictions on TEST data (contains anomalies)
- Call `writer.save_scores()` with correct result_type and ground_truth
- Call `writer.add_metadata()` with all hyperparameters and a `summary` holding
`scored_split`, `scored_samples` and the method's own AUC over the published scores
- Call `writer.finalize()`
### Step 4: Describe the Dataset (only if it is new)
Create `graflag-shared/datasets/{dataset_name}/metadata.json` naming the files
and their source URLs, then fetch them with `graflag-data fetch {dataset_name}`.
Never commit data files.
### Step 5: Verify Integration
Pass the four gates, in order, and report each for every method:
- [ ] Contract: `python3 -m unittest discover -s tests` in `graflag-shared/`
- [ ] Build and run: `graflag sync`, then `graflag run -m {method} -d {dataset} --build` completes
- [ ] Evaluation: `graflag evaluate -e {experiment}`
- [ ] Result integrity: `graflag verify -e {experiment}` reports no failure, and every `[WARN]` it prints is explained in the method's README
- [ ] Recorded: the README's `## Verification` section gives each gate's result (or why the method cannot run here); gate 1 refuses a method that records nothing
## Critical Requirements
1. **Parameters**: one `Config` dataclass built with `Config(**params(Config))`, or
upstream's own namespace through `apply_params()` -- never an argparse parser
of your own, no `str2bool`, no per-key `os.environ.get`
2. **Booleans**: `_FLAG=true` or `_FLAG=false`, never a bare `_FLAG=`
3. **No committed data**: a dataset is a `metadata.json` descriptor; its files are fetched
4. **TEST data**: Predictions must be on test data with anomalies, not training data
5. **ground_truth**: Always include ground_truth in save_scores()
6. **Reserved vars**: Never override DATA, EXP, METHOD_NAME, COMMAND, MONITOR_INTERVAL
## Reference Implementations
Study these existing integrations:
- `methods/example/` -- the annotated template; start here
- `methods/taddy/` -- an integration script over a pinned clone, `Config(**params(Config))`
- `methods/generaldyg/` -- adopts upstream's argparse namespace via `apply_params()` (as does `ada_gad`)
- `methods/gady/` -- the hard case: upstream imported, training loop reproduced, inert parameters declared
- `methods/bond_cola/` -- a `.env` and nothing else, on the shared `bond_base` image
## Workspace Paths
- Methods: `graflag-shared/methods/`
- Datasets: `graflag-shared/datasets/`
- Libraries: `graflag-shared/libs/` (graflag_runner, graflag_bond, graflag_evaluator, graflag_data)
Example: Complete GADY Integration
methods/gady/ is the method that exercises every rule on this page, so it is
worth reading in full. What follows is the shape; methods/gady/README.md is
where the reasoning lives.
.env
METHOD_NAME=gady
DESCRIPTION=Unsupervised Anomaly Detection on Dynamic Graphs (WSDM 2024)
SOURCE_CODE=https://github.com/mufeng-74/GADY
SOURCE_REF=1e8e4503e238f0a6c093c3aba946085aa3959410
INTEGRATION=upstream
SUPPORTED_DATASETS=gady_*
COMMAND=python3 train_graflag.py
_BS=200
_N_EPOCH=50
_LR_G=0.000001
_LR_D=0.000001
_USE_MEMORY=true
_SEED=142
_GPU=0
# ... twenty more, among them the inert _LR (see below)
_USE_MEMORY=true, spelled out. This .env carried the bare _USE_MEMORY=
from the --pass-env-args era, which params() reads as False – so the
method would have trained with memory disabled and said nothing.
train_graflag.py (key parts)
@dataclass
class Config:
"""Every parameter GADY accepts, with the default it takes when unset."""
bs: int = 200
n_epoch: int = 50
use_memory: bool = False
lr_g: float = 0.000001
lr_d: float = 0.000001
seed: int = 142
gpu: int = 0
# ... one field per _FOO in .env
config = Config(**params(Config))
This dataclass replaced three competing paths that each decided the same
value: an argparse parser fed by --pass-env-args, a 30-key env_mappings
dict, and a loop in main() copying the second over the first. The dict read
ANOMALY_PER where GraFlag injects _ANOMALY_PER, so all thirty lookups
returned None and the whole path was dead – and two of its entries were
additionally misspelled, which nothing could reveal while it never ran.
What gady still shows that the templates do not
Upstream’s code runs, but upstream’s
train.pydoes not.train.pyreadsargs.alphafor a flag its parser never declares, so the module raisesAttributeErrorbefore any training code. The model, generator, losses, samplers and data pipeline are imported from the clone; the training loop is reproduced.README.mdsays so, and says where the two differ.Inert parameters are declared, not deleted.
_LRis accepted because removing it would make a--params LR=...sweep look like it worked. Setting it off its default prints a[WARN]naming it.A missing input stops the run. The positional features are written by upstream’s
preproc_new.py; the loader raises with that sentence in the message rather than skipping the file. An earlier version caughtFileNotFoundErrorandcontinued, which turned a missing artifact into an epoch that trained on nothing.
Summary
Choose the shape: an integration script (
COMMAND=python3 train_graflag.py) unless a library already covers the family, asgraflag_bonddoes for PyGOD.env: lowercase method name matching the directory,_prefix for every parameter, booleans spelled out,SOURCE_REF+INTEGRATIONfor provenance,SUPPORTED_DATASETSfor compatibility,IMAGE=only to share an existing imageDockerfile: a base that matches the method (CUDA for GPU code), pinned clone guarded bytest -n, theARG GRAFLAG_LIBSstanza copied verbatim,CMD ["python3", "-m", "graflag_runner"]– or no Dockerfile at all ifIMAGE=names a shared onetrain_graflag.py: copymethods/example/, declare aConfigdataclass, build it withConfig(**params(Config))(orapply_params()on upstream’s namespace), take the device fromdevice(), the dataset fromload_dataset(), the directories frompaths(); score the TEST split and write it withResultWriterDataset: a
metadata.jsondescriptor, fetched withgraflag-data; never committed data files
Key rule: when the original method works and the integration does not, look
first at what the method was given – a parameter that reached nothing, a
boolean that arrived inverted, a device string built from _GPU=-1, or a file
that was missing and skipped. Every one of those failed silently at least once
here, which is why the SDK does each of them in exactly one place.