A service-oriented platform for bottleneck-centric network data generation. Researchers describe an experiment in natural language, and the platform decomposes it into reproducible runs against shaped, instrumented infrastructure — with cross-traffic, traffic capture, and telemetry storage handled end-to-end.
A five-minute walkthrough: bring the stack up, run one shaped wget download, pull its IDs, and open the analysis notebook. Concepts and reference live further down; this is just the happy path.
- macOS or Linux host with Docker + Docker Compose v2 (Docker Desktop 24+ works).
**sudo** (only if your user is not in thedockergroup —docker compose upneeds to write to the docker socket).curlon the host.- An LLM API key —
ANTHROPIC_API_KEYorGOOGLE_API_KEY(setORCHESTRATOR_LLM_PROVIDER=geminiif you use Google). - Python 3.10+ — used by the Quick Start snippets below to extract IDs from JSON, and required for running the analysis notebook locally (otherwise open it in the VS Code Jupyter extension).
git clone --recurse-submodules https://github.com/SNL-UCSB/agentic-thin-waist.git
cd agentic-thin-waist
cp .env.example .env # then edit .env and paste your API key--recurse-submodules pulls the NetGent submodule under shared/clients/netgent. If you already cloned without it, run git submodule update --init --recursive.
Then bring up the stack one of two ways:
Option A — build from source (slower, builds every image locally):
make build # docker compose build
make up # docker compose up -d, waits for /health, prints URLs
make status # docker compose ps — every service should be (healthy)Equivalent raw commands:
docker compose build && docker compose up -d && docker compose ps.
Option B — use prebuilt images (faster, pulls published snlhub/* images from Docker Hub):
docker compose -f docker-compose.yml -f docker-compose.public.yml pull
docker compose -f docker-compose.yml -f docker-compose.public.yml up -d
docker compose ps # every service should be (healthy)The orchestrator's POST /intent is the recommended entry point. The context block deterministically pins the queue size, AQM, CC, and duration so the parser does not have to infer them from the prose.
ORCH_ID=$(curl -s -X POST http://localhost:8005/intent \
-H "Content-Type: application/json" \
-d '{
"intent": "Run a wget download from https://speed.cloudflare.com/__down?bytes=10485760 over a 10 Mbps bottleneck with 20 ms added latency, pfifo queue, the queue size of 200 packets and cubic congestion control. One trial, 30 seconds",
"workflow_source": "library",
"context": {
"aqm_policy": "pfifo",
"buffer_packets": 200,
"qdisc_params": {},
"cc_algorithms": ["cubic"],
"duration_seconds": 30,
"num_trials": 1
}
}' | python3 -c "import json,sys;print(json.load(sys.stdin)['orchestration_id'])")
echo "orchestration_id = $ORCH_ID"while STATUS=$(curl -s http://localhost:8005/orchestration/$ORCH_ID | python3 -c "import json,sys;print(json.load(sys.stdin)['status'])"); \
[ "$STATUS" != "complete" ] && [ "$STATUS" != "failed" ]; do
echo "$(date +%T) $STATUS"
sleep 5
done
echo "final: $STATUS"Expect ~1.5–2 minutes total: ephemeral worker provisioning, the synchronized capture + run window (30 s shaped wget), then artifact upload to telemetry.
EXP=$(curl -s http://localhost:8005/orchestration/$ORCH_ID/results \
| python3 -c "import json,sys;print(json.load(sys.stdin)['results'][0]['experiment_id'])")
RID=$(curl -s "http://localhost:8004/results?experiment_id=$EXP&limit=1" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['results'][0]['result_id'])")
echo "exp=$EXP rid=$RID"Open services/analysis/analyze_queue.ipynb and paste the printed $EXP value into the second code cell:
EXPERIMENT_ID = os.environ.get('EXPERIMENT_ID', 'PASTE-$EXP-HERE')Then Run All. The notebook will:
- Hit
http://localhost:8004(telemetry) and download the pcap + queue_trace. - Print a contextual-tree summary (you should see
buffer_packets: 200,qdisc: pfifo). - Plot download/upload throughput (from pcap), bottleneck queue occupancy, and drop rate (from
/qtrace). For a successful run, the download queue (veth2, blue) saturates at the 200-packet limit and the drop-rate panel spikes during the bulk transfer.
Tip: skip the manual paste with
EXPERIMENT_ID=$EXP jupyter nbconvert --to notebook --execute --inplace services/analysis/analyze_queue.ipynbRunning the stack on a remote VM and want the plots on your laptop? See services/analysis/README.md § Headless for the venv setup, HTML render, and scp-back recipe.
make down # stop containers, keep volumes (fast restart)
make clean # docker compose down -v + remove pyc/pytest cachesmake clean deletes telemetry's Postgres + MinIO volumes too, so any previously-stored experiment results are wiped — use make down if you want to keep them around.
Progress in networking research depends on data that captures how applications and protocols respond to diverse, time-varying bottleneck regimes. Generating such data systematically is hard: bottleneck dynamics are simultaneously behavior-defining and execution-dependent, making them hard to replicate, vary, or reuse across environments.
This platform applies the hourglass design from netUnicorn to network data generation. The "thin waist" is a composable set of services that bridges diverse research intents (top) with diverse infrastructure (bottom):
RESEARCH INTENTS
Replication | Counterfactual | Pre-training | Broadband
Teaching | DOE Synthesis | Benchmarking | Hypothesis
─────────────────────────────────────────────────────────
\ any researcher, any question /
\ /
────────────────────────────────────────────
| AGENTIC THIN WAIST |
| |
| Orchestration (LLM + LangGraph) |
| Experiment API + CTP + Substrate Worker |
| ├─ Intent (Link/Bottleneck) |
| ├─ Representation (CTPs) |
| └─ Execution (tc, tshark, …) |
| NetGent Service (application workflows)|
| Telemetry Service |
| |
────────────────────────────────────────────
/ \
/ any Docker-capable host \
─────────────────────────────────────────────────────────
Laptop Docker | AWS EC2 | PINOT Campus | ANL/ESnet
Mininet | Azure | SNL Servers | NetUnicorn
Researchers specify intents at the top. Agents orchestrate execution through the waist. Infrastructure adapts at the bottom.
The architecture is guided by progressive disaggregation, developed across our prior systems:
- netUnicorn — separates what to collect from how to realize it, and disaggregates intents into reusable tasks.
- NetForge / NetReplica — disaggregates bottleneck-centric data generation along three dimensions: intent vs. execution, static vs. dynamic attributes, and trace vs. context (via Cross-Traffic Profiles).
- BQT+ — disaggregates workflow specification from execution for web-based measurement, modeling consumer-facing interfaces as nondeterministic finite automata.
- NetGent — extends BQT+'s NFA abstraction to general application workflows (YouTube, Netflix, Zoom, …), compiling natural-language workflow specs into executable state machines.
Together, these systems form the building blocks of the thin-waist platform.
CONTROL PLANE DATA PLANE
(researcher / SNL server) (wherever experiments run)
┌──────────────────────────────────┐ ┌──────────────────────────┐
│ │ │ │
│ Orchestration :8005 │ specs │ Substrate Workers :8002 │
│ (LLM + LangGraph + OpenClaw) │──────►│ (tc, tshark, tcpreplay) │
│ │ │ │
│ Experiment API :8000 │ │ Browserless + │
│ (intent plane) │ │ NetGent worker │
│ │ │ (app workflows) │
│ CTP Service :8001 │ │ │
│ (representation plane) │results│ │
│ │◄──────│ │
│ NetGent Service:8003 │ │ │
│ Telemetry :8004 │ │ │
└──────────────────────────────────┘ └──────────────────────────┘
(PCAPs, tcp-info, metrics)
The Control Plane runs on the researcher's machine or an SNL server. The Data Plane runs wherever experiments execute — locally via Docker, on AWS EC2 in hybrid mode, or in future on remote campus hosts.
| Service | Port | Plane | Purpose |
|---|---|---|---|
| Experiment API | 8000 | Intent | Receives experiment specs, coordinates CTP + Substrate, persists status. |
| CTP Service | 8001 | Representation | Stores Cross-Traffic Profiles. Supports extract, select, transform, merge, replay. |
| Substrate Worker | 8002 | Execution | Applies tc qdisc/netem, runs tshark capture, replays CTP via tcpreplay. Sole owner of all kernel-level network operations. |
| NetGent Service | 8003 | Application | Browser/shell workflow engine (NFA-based). Async job API. |
| Telemetry Service | 8004 | Storage | Stores results + artifacts (PCAPs, JSON), exposes filtered queries. |
| Orchestration | 8005 | Agentic | NL intent → ParsedIntent → Cartesian product of experiments → end-to-end execution. |
- Orchestrator parses the NL intent (LLM call) into
ParsedIntent. ExperimentGeneratorexpands it into oneGeneratedExperimentper (capacity × latency × CC) combination, each with a globally-unique ID (see Experiment IDs below).- For each spec, the orchestrator:
- Provisions an ephemeral substrate worker (local Docker by default).
- Picks a CTP from the CTP Service that matches the intensity range.
- Tells the worker to fetch the CTP PCAPs.
- Synchronizes three actions on the next whole-minute boundary: start tshark capture, start
tcpreplay-editbackground traffic, and start the application workflow. - Stops replay, drains capture, streams the resulting PCAP to telemetry.
- Destroys the worker.
The Quick Start above covers the happy path. This section is the reference: full prerequisite list, every make / docker compose target, the generic /intent payload (any application, not just wget), and direct calls into the lower-level services for debugging.
- Docker + Docker Compose (24+).
- Python 3.10+ (for local development).
- An LLM API key —
ANTHROPIC_API_KEY(default) orGOOGLE_API_KEY(if you switch the orchestrator to Gemini).
git clone --recurse-submodules git@github.com:SNL-UCSB/agentic-thin-waist.git
cd agentic-thin-waist
cp .env.example .env # then edit .env with your API key + paths--recurse-submodules pulls the NetGent submodule under shared/clients/netgent. If you already cloned without it, run git submodule update --init --recursive.
docker compose build # build all images
sudo docker compose up -d # start everything (detached)
docker compose ps # service status
docker compose logs -f # tail logs for everything
docker compose logs -f orchestration # tail one service
docker compose down # stop
docker compose down -v # stop + remove volumesEquivalent make shortcuts are defined in the Makefile:
| Make target | Underlying command | Extras |
|---|---|---|
make build |
docker compose build |
— |
make up |
docker compose up -d |
polls localhost:8000/health for up to 30s, then prints service URLs |
make status |
docker compose ps |
— |
make logs |
docker compose logs -f |
— |
make logs-service SERVICE=<x> |
docker compose logs -f <x> |
— |
make down |
docker compose down |
— |
make clean |
docker compose down -v |
also removes __pycache__, *.pyc, .pytest_cache, build/, dist/, *.egg-info |
Once the stack is up, the services are reachable on:
Experiment API http://localhost:8000
CTP Service http://localhost:8001
Substrate Worker http://localhost:8002
NetGent Service http://localhost:8003
Telemetry http://localhost:8004
Orchestration http://localhost:8005
The recommended entry point is the orchestrator's POST /intent:
curl -X POST http://localhost:8005/intent \
-H "Content-Type: application/json" \
-d '{
"intent": "Run iperf3 at 40 Mbps with 100 ms latency under cubic and bbr",
"context": { "duration_seconds": 60, "num_trials": 1 },
"preferences": {},
"workflow_source": "auto"
}'The response is 202 Accepted with an orchestration_id. Track progress and pull results:
curl http://localhost:8005/orchestration/<orch_id> # status + per-experiment progress
curl http://localhost:8005/orchestration/<orch_id>/results # aggregated results
curl http://localhost:8005/orchestration/<orch_id>/reasoning # agent reasoning traceThe orchestrator generates one experiment per (capacity × latency × CC) combination, each with an ID like iperf3_40_40_100_pfifo_cubic_a2d6e73d.
workflow_source controls where the NetGent workflow comes from:
| Value | Meaning |
|---|---|
auto (default) |
LLM picks a confident match from the workflow library; if none, the LLM generates a fresh workflow. |
library |
Library only — fail if no match. |
generate |
Skip the library, always generate. |
Pin a specific workflow with "workflow_id": "test_ndt_workflow" (overrides workflow_source). The deployment-wide default for workflow_source is ORCH_DEFAULT_WORKFLOW_SOURCE (defaults to auto).
You can bypass the orchestrator and exercise individual services for debugging or scripted runs:
# CTP selection
curl -X POST http://localhost:8001/ctps/select -H 'Content-Type: application/json' \
-d '{"intensity_range_mbps":[1,10]}'
# Apply traffic shaping on a substrate worker
curl -X POST http://localhost:8002/shape -H 'Content-Type: application/json' \
-d '{"download_mbps":40,"upload_mbps":40,"latency_ms":100,"qdisc":"pfifo"}'
# Start replay (note PNAT default — see below)
curl -X POST http://localhost:8002/replay -H 'Content-Type: application/json' \
-d '{"ctp_file":"cluster26_tree10_profile424",
"pnat":"169.231.0.0/16:172.16.1.20,128.111.0.0/16:172.16.1.20",
"duration_seconds":60}'Each service's README documents its full HTTP surface:
- services/experiment-api/README.md
- services/ctp-service/README.md
- services/substrate-worker/README.md
- services/netgent-service/README.md
- services/telemetry-service/README.md
- services/orchestration/README.md
A few knobs landed recently that may not be documented elsewhere:
Each generated experiment gets a globally-unique, descriptive ID:
{app}_{download_mbps}_{upload_mbps}_{latency_ms}_{aqm}_{cc}_{uuid8}
# e.g. iperf3_40_40_100_pfifo_cubic_a2d6e73d
# youtube_25_25_50_fq_codel_bbr_77856217
app is taken from the first entry of parsed_intent.applications (slugified to lowercase alphanumerics), falling back to application_type (shell / browser). When TELEMETRY_SERVICE_URL is configured, each candidate ID is also checked against GET /results?experiment_id=<id>&limit=1 so it can't collide with anything already persisted. Implementation: [services/orchestration/app/engine/experiment_generator.py](services/orchestration/app/engine/experiment_generator.py).
If the intent does not mention cross-traffic, the orchestrator now treats CTP as absent: ctp_cluster and ctp_capacity_range stay null, _select_ctp short-circuits to None, and /replay is skipped on the worker. Previously a silent 1–10 Mbps default was synthesized, which polluted every captured pcap with 172.16.1.20 packets the user hadn't asked for.
To opt in, the intent (or context) must populate ctp_capacity_range ({"lower_value": float, "higher_value": float} in Mbps) or ctp_cluster. Implementation: [services/orchestration/app/engine/orchestration_manager.py](services/orchestration/app/engine/orchestration_manager.py) (_select_ctp) and [services/orchestration/app/engine/experiment_generator.py](services/orchestration/app/engine/experiment_generator.py).
When CTP is requested, background cross-traffic is replayed by the substrate worker via tcpreplay-edit --pnat=…. The default rule sends replayed packets to **172.16.1.20** — distinct from the application's interface IP (172.16.1.1) — so a captured PCAP can be split between application traffic and replayed cross-traffic by IP.
Override per experiment: set replay_pnat_ip on GeneratedExperiment (a single target IP; the standard source subnets 169.231.0.0/16 and 128.111.0.0/16 are reused).
Override globally: set SUBSTRATE_REPLAY_PNAT to a full rewrite rule.
Details and resolution order: [services/orchestration/README.md § CTP Replay & PNAT](services/orchestration/README.md#ctp-replay--pnat).
The substrate worker now forces every shell workflow dispatched via POST /run to execute inside the ns1 network namespace (via nsenter -- ip netns exec ns1 <cmd>), regardless of what NETGENT_USE_LOCAL / NETGENT_NAMESPACE are set to in the container environment. This is hardcoded at module load in [services/substrate-worker/src/substrate/main_local.py](services/substrate-worker/src/substrate/main_local.py) (and the main.py / main_aws.py siblings).
Why: with the old default, wget/iperf/etc. ran in the worker's root namespace and reached the internet via eth0, so the pcap on veth2 only ever captured the substrate's iperf3 self-test instead of the actual workload. The fix routes the workload through the shaped veth pair so the existing tc rules apply to its packets.
Today's wget / iperf / ping / ndt adapters have no wallclock cap of their own (wget's --timeout only fires on stalls), so a slow-but-progressing workload would run past the capture window. The substrate worker now bounds every shell command by a wallclock deadline:
- The orchestrator passes
spec.duration_secondsasexperiment_max_secondson the/runpayload. - The substrate worker monkey-patches NetGent's
run_subprocessto honor it. On timeout it sends SIGTERM, waits 2 s, then SIGKILL. - Partial stdout/stderr written before the kill are preserved and returned with
returncode=124(coreutilstimeout(1)convention). - The
/runresponse carriesterminated_at_deadline: bool. The pcap, qtrace, and telemetryPOST /resultsall proceed as if the workflow completed normally — only the inner action shows a non-zero rc.
So a 50 MiB download on a 10 Mbps / 30 s experiment now terminates cleanly at the deadline with terminated_at_deadline=true, ~8 MiB visible in the pcap, and the experiment row marked success. Applies to every shell adapter that goes through run_subprocess (wget, iperf, ping, ndt); browser/playwright workflows have no deadline yet. Disable by omitting duration_seconds (no upper bound applied).
Implementation: top of [services/substrate-worker/src/substrate/main_local.py](services/substrate-worker/src/substrate/main_local.py); tests in [services/substrate-worker/tests/test_shell_deadline.py](services/substrate-worker/tests/test_shell_deadline.py).
The choice between "use the workflow library" and "have the LLM generate a workflow" is now a request property, not a deployment env var. Fields on ResearchIntent:
workflow_source:"auto"(default — library first, fall back to generation) /"library"(fail if no match) /"generate"(skip the library).workflow_id: explicit pin (e.g."test_ndt_workflow") — overridesworkflow_source.
Library selection is LLM-driven against the NetGent workflow index. The previous keyword inference (iperf → test_iperf_workflow, etc.) and the ORCH_FORCE_EXISTING_WORKFLOW / ORCH_FORCE_WORKFLOW_ID env vars are gone. Deployments can set ORCH_DEFAULT_WORKFLOW_SOURCE to change the default when the request doesn't specify one.
Details: [services/orchestration/README.md § Workflow Source](services/orchestration/README.md#workflow-source).
Bottleneck queue size is now a first-class experiment parameter. Pass it deterministically in context:
Every experiment also captures a queue-occupancy trace via the substrate worker's /qtrace endpoint — tc -s polled at 5 ms cadence on veth2 and veth4, uploaded to telemetry as a queue_trace artifact next to the pcap. Plot it with [services/analysis/analyze_queue.ipynb](services/analysis/analyze_queue.ipynb) — the notebook now lays out download throughput (left), upload throughput (right), and per-interface queue occupancy / drop rate on a shared time axis. Disable globally with ORCH_QTRACE_ENABLED=false.
The split uses two new pcap helpers re-exported from services.analysis: filter_downlink(pkts) keeps packets with ip.dst == 172.16.1.1 and filter_uplink(pkts) keeps packets with ip.src == 172.16.1.1 (the app client lives in ns1 on 172.16.1.1). On the queue side, veth2 is the root↔ns1 leg → download queue, veth4 is the root↔ns2 leg → upload queue.
Details: [services/orchestration/README.md § Queue-Occupancy Trace](services/orchestration/README.md#queue-occupancy-trace-qtrace) and [services/substrate-worker/README.md § POST /qtrace](services/substrate-worker/README.md#post-qtrace).
agentic-thin-waist/
├── README.md # this file
├── docker-compose.yml # local dev stack
├── docker-compose.cloud.yml # AWS hybrid mode
├── docker-compose.test.yml # CI test stack
├── Makefile # common tasks
├── .env.example # template — copy to .env and fill in
│
├── services/
│ ├── experiment-api/ # Intent plane (port 8000)
│ ├── ctp-service/ # Representation plane (port 8001)
│ ├── substrate-worker/ # Execution plane (port 8002)
│ ├── browserless-substrate-worker/ # Headless browser pool, shares ns w/ substrate-worker
│ ├── netgent-service/ # App workflow engine (port 8003)
│ ├── telemetry-service/ # Results + artifact storage (port 8004)
│ ├── orchestration/ # NL intent → experiments (port 8005)
│ ├── netforge-setup/ # Substrate provisioning helpers
│ └── analysis/ # Result-analysis package + notebooks
│
├── shared/
│ ├── models/ # Dataclass contracts shared across services
│ ├── clients/ # Service client libraries
│ ├── db/ # Shared DB utilities
│ ├── s3/ # MinIO/S3 helpers
│ └── tests/
│
├── docs/
│ ├── vision.md
│ ├── thin_waist_one_pager.md
│ ├── demo_plan_april_2026.md
│ ├── lit-survey/
│ └── user_study/
│
└── tests/ # Cross-service integration tests
docker compose build # all Docker images
docker compose up -d # start stack
# Full test suite (runs orchestration-tests container, exits when done)
docker compose -f docker-compose.yml -f docker-compose.test.yml \
up --build --abort-on-container-exit --exit-code-from orchestration-tests \
orchestration-tests
# or: make test
# Repo-level pytest (no containers)
pytest tests/ -v
# or: make test-local
# Single-service test runs
cd services/<name> && pytest tests/ -v
cd services/<name> && pytest tests/ -v --cov=app --cov-report=term-missing
cd shared && pytest tests/ -vBlack is the only enforced formatter. CI pins black==26.3.1 and only checks changed .py files on PRs.
black services/ shared/ tests/
black --check services/ shared/ tests/- Branch naming:
<username>/<area>/<short-description>(e.g.jaber/orchestration/fix-replay). - Commit prefix: the service or area being touched, e.g.
orchestration: …,substrate-worker: …,shared: …. - One logical change per PR.
GitHub Actions runs per-service on PRs touching that service's path or shared/. Each service workflow builds the image, runs pytest inside it, then starts the service via Compose to verify it comes up healthy. black.yml checks formatting on changed files; shared-tests.yml runs the shared library tests when shared/ changes.
| ID | Lead | Service(s) | Status |
|---|---|---|---|
| D1 — NetForge Service (Intent / Representation / Execution) | Jaber, Satyam, Snithik | experiment-api, ctp-service, substrate-worker | Active |
| D2 — NetGent Programmatic API | Eugene + Jaber | netgent-service | Active |
| D3 — Telemetry & Storage Pipeline | Manni | telemetry-service | Active |
| D4 — Evaluation Pipeline | — | analysis | Stretch |
| D5 — Agentic Orchestration | Haarika | orchestration | Active — NL intent end-to-end working |
A bottleneck regime comprises (i) a static envelope — capacity, base latency, buffering, and queue management — and (ii) a time-varying congestion-pressure process that drives contention within that envelope. NetForge makes this explicit by disaggregating static and dynamic attributes into independently controllable specs.
A reusable representation of dynamic congestion pressure applied at a bottleneck. CTPs encode the temporal structure of aggregate demand — intensity, burstiness, heterogeneity, temporal correlations — without binding to the path, applications, or users that produced it. Operations: extract, select, transform, merge, replay.
- Intent — researcher expresses goal in natural language (or a structured spec).
- Decomposition — orchestration parses intent and generates one experiment per parameter combination.
- Static spec — capacity, latency, buffer, AQM applied via tc.
- Dynamic spec — CTP selected from the corpus.
- Execution — Substrate Worker applies shaping, runs CTP replay, runs the application workflow.
- Collection — tshark captures upstream and downstream PCAPs.
- Storage — results + PCAPs persisted to Telemetry Service, tagged with full context.
Controllability (independent knobs for intent, static structure, and dynamic pressure), composability (mix-and-match), replicability (same specs re-instantiate comparable regimes across runs), and fidelity (preserve realistic queueing signals and closed-loop application–bottleneck interaction).
- netUnicorn (CCS '23) — Data-collection platform with hourglass design and SOA.
- NetForge (SIGCOMM submission #1035) — Programmable substrate for bottleneck-centric data generation.
- BQT+ (SIGCOMM '26 submission) — NFA-based broadband plan measurement.
- NetReplica — private SNL-UCSB repo.
- Linux Traffic Control: https://man7.org/linux/man-pages/man8/tc.8.html
- PI: Prof. Arpit Gupta (SNL-UCSB)
- Org: SNL-UCSB
- Repository: github.com/SNL-UCSB/agentic-thin-waist (private)
Proprietary — SNL-UCSB. All rights reserved.