protocol_one_env / scripts /build_sft_dataset.py
suhaniawasthi's picture
Upload folder using huggingface_hub
6ff09d1 verified
Raw
History Blame Contribute Delete
23 kB
#!/usr/bin/env python
"""Build a Rejection-Sampling SFT dataset from live env rollouts.
Pipeline:
1. Spin up MockProtocolServer (optionally with a Designer mutation).
2. A scripted prober drives ~8-15 probes against it, varying strategy
so transcripts diverge across episodes.
3. An observation-grounded interpreter derives the belief graph the
probes actually justify (no hallucinations beyond the evidence).
4. matcher.score() rates the belief graph against the (mutated) spec.
5. Keep only episodes where score >= --threshold (default 0.45).
6. Write JSONL: {"prompt", "completion", "score", "spec_variant",
"n_probes", "breakdown"}.
The result is a clean (transcript -> belief_graph_json) dataset that an
LLM can be SFT'd on. Inference time, the model takes a fresh transcript
from the live env and emits a structured belief graph -- exactly the
mapping the matcher rewards.
Usage:
python -m scripts.build_sft_dataset --episodes 2000 --out data/sft.jsonl
python -m scripts.build_sft_dataset --episodes 200 --threshold 0.4 \\
--mutation-prob 0.3 --out data/sft_with_mutations.jsonl
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import random
import sys
from collections import Counter
from dataclasses import dataclass
from typing import Any
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from fastapi.testclient import TestClient # noqa: E402
from server.designer import Designer # noqa: E402
from server.matcher import normalize_path, score # noqa: E402
from server.protocol_server import MockProtocolServer # noqa: E402
from server.spec import INITIAL_TOKEN, SPEC, TOKENS # noqa: E402
# ---------------------------------------------------------------------------
# Prompt template
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = (
"You are an automated API characterization agent for in-house development "
"services. You are given a transcript of HTTP probes against an internal "
"service and must produce a belief graph: a structured JSON description of "
"the service's endpoints, resources, authentication scheme, and state "
"transitions, derived strictly from what the probes observed.\n\n"
"Output exactly one JSON object with these top-level keys: "
"`endpoints`, `resources`, `auth`. Use {id} for path placeholders. "
"Do not include endpoints you have no evidence for. Do not wrap the JSON "
"in markdown fences -- emit raw JSON only."
)
USER_TEMPLATE = (
"Probe transcript ({n} probes):\n"
"{transcript}\n\n"
"Emit the belief graph as a single JSON object. No prose, no fences."
)
# ---------------------------------------------------------------------------
# Probe strategies
# ---------------------------------------------------------------------------
@dataclass
class Probe:
method: str
path: str
headers: dict[str, str]
body: dict | None = None
AUTH_HEADERS = {tok: {"Authorization": f"Bearer {tok}"} for tok in TOKENS}
AUTH_NONE: dict[str, str] = {}
def _strategy_broad(rng: random.Random) -> list[Probe]:
"""Hit one or two probes per major endpoint family."""
return [
Probe("GET", "/users", AUTH_NONE),
Probe("GET", "/users", AUTH_HEADERS[INITIAL_TOKEN]),
Probe("GET", "/users/u_alice", AUTH_HEADERS[INITIAL_TOKEN]),
Probe("GET", "/docs", AUTH_HEADERS[INITIAL_TOKEN]),
Probe("GET", "/docs/d_intro", AUTH_HEADERS[INITIAL_TOKEN]),
Probe("GET", "/auth/whoami", AUTH_HEADERS[INITIAL_TOKEN]),
Probe("GET", "/_/health", AUTH_NONE),
Probe("GET", "/auth/scopes", AUTH_HEADERS["token_admin"]),
]
def _strategy_auth_explorer(rng: random.Random) -> list[Probe]:
"""Probe one endpoint with every token to map the scope system."""
return [
Probe("GET", "/users", AUTH_NONE),
Probe("GET", "/users", AUTH_HEADERS["token_full"]),
Probe("GET", "/users", AUTH_HEADERS["token_read"]),
Probe("GET", "/users", AUTH_HEADERS["token_write"]),
Probe("GET", "/docs", AUTH_HEADERS["token_read"]),
Probe("GET", "/docs", AUTH_HEADERS["token_write"]),
Probe("GET", "/auth/scopes", AUTH_HEADERS["token_read"]),
Probe("GET", "/auth/scopes", AUTH_HEADERS["token_admin"]),
Probe("GET", "/auth/whoami", AUTH_HEADERS["token_full"]),
]
def _strategy_state_machine(rng: random.Random) -> list[Probe]:
"""Exercise document and user state transitions to elicit 409s."""
full = AUTH_HEADERS[INITIAL_TOKEN]
return [
Probe("GET", "/docs", full),
Probe("POST", "/docs/d_specs/publish", full), # draft -> published
Probe("POST", "/docs/d_specs/archive", full), # published -> archived
Probe("POST", "/docs/d_old/publish", full), # archived -> 409
Probe("POST", "/docs/d_intro/publish", full), # already published -> 409
Probe("POST", "/users/u_alice/suspend", full), # active -> suspended
Probe("POST", "/users/u_alice/restore", full), # suspended -> active
Probe("POST", "/users/u_carol/suspend", full), # already suspended -> 409
Probe("PATCH", "/docs/d_intro", full, {"title": "x"}), # not draft -> 409
]
def _strategy_resource_shape(rng: random.Random) -> list[Probe]:
"""Hit listing + getter endpoints so resource fields show up in bodies."""
full = AUTH_HEADERS[INITIAL_TOKEN]
return [
Probe("GET", "/users", full),
Probe("GET", "/users/u_alice", full),
Probe("GET", "/users/u_bob", full),
Probe("GET", "/users/u_alice/documents", full),
Probe("GET", "/docs", full),
Probe("GET", "/docs/d_intro", full),
Probe("GET", "/docs/d_specs", full),
Probe("GET", "/auth/whoami", full),
]
def _strategy_negative_space(rng: random.Random) -> list[Probe]:
"""Probe paths that may or may not exist to learn 404 vs 401 boundary."""
full = AUTH_HEADERS[INITIAL_TOKEN]
return [
Probe("GET", "/users", AUTH_NONE), # 401 (real, no auth)
Probe("GET", "/admin", AUTH_NONE), # 404 (not real)
Probe("GET", "/users/nonexistent_id", full), # 404 (real path, missing id)
Probe("DELETE", "/users/u_bob", full),
Probe("DELETE", "/users/u_bob", full), # 410 idempotent
Probe("POST", "/users", full, {"email": "n@ex.com"}),
Probe("POST", "/users", full, {"email": "alice@example.com"}), # 409 conflict
Probe("POST", "/users", full, {"email": "bad"}), # 422
]
_STRATEGIES = [
_strategy_broad,
_strategy_auth_explorer,
_strategy_state_machine,
_strategy_resource_shape,
_strategy_negative_space,
]
def pick_probes(rng: random.Random, max_probes: int) -> list[Probe]:
"""Mix one primary strategy with a few probes from another for diversity."""
primary = rng.choice(_STRATEGIES)(rng)
secondary_fn = rng.choice([s for s in _STRATEGIES if s is not primary])
secondary = secondary_fn(rng)
rng.shuffle(secondary)
probes = primary + secondary[: max(0, max_probes - len(primary))]
return probes[:max_probes]
# ---------------------------------------------------------------------------
# Probe execution and transcript formatting
# ---------------------------------------------------------------------------
def execute_probes(client: TestClient, probes: list[Probe]) -> list[dict]:
"""Run probes; return list of {method, path, headers, body, status, response}."""
out: list[dict] = []
for p in probes:
try:
resp = client.request(
method=p.method,
url=p.path,
headers=p.headers,
json=p.body if p.body is not None else None,
)
try:
resp_body = resp.json()
except Exception:
resp_body = resp.text
except Exception as e:
resp = None
resp_body = {"error": f"client_exception: {e}"}
out.append({
"method": p.method,
"path": p.path,
"auth_token": p.headers.get("Authorization", "").replace("Bearer ", "") or None,
"request_body": p.body,
"status": getattr(resp, "status_code", 0),
"response": resp_body,
})
return out
def format_transcript(probes: list[dict]) -> str:
"""Compact, model-friendly transcript. Truncates long bodies."""
lines: list[str] = []
for i, p in enumerate(probes, 1):
auth = f" [auth={p['auth_token']}]" if p["auth_token"] else " [auth=none]"
body_part = ""
if p["request_body"] is not None:
body_part = f" body={json.dumps(p['request_body'])}"
resp = p["response"]
if isinstance(resp, (dict, list)):
resp_str = json.dumps(resp, separators=(",", ":"))
else:
resp_str = str(resp)
if len(resp_str) > 320:
resp_str = resp_str[:320] + "…"
lines.append(
f"[{i}] {p['method']} {p['path']}{auth}{body_part}\n"
f" -> HTTP {p['status']} {resp_str}"
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Observation-grounded belief graph derivation
# ---------------------------------------------------------------------------
def _spec_endpoint_match(method: str, raw_path: str, spec: dict) -> dict | None:
"""Find the spec endpoint matching this (method, raw_path), if any."""
npath = normalize_path(raw_path)
method = method.upper()
for ep in spec["endpoints"]:
if ep["method"].upper() != method:
continue
if normalize_path(ep["path"]) == npath:
return ep
for alias in ep.get("aliases", []):
if normalize_path(alias) == npath:
return ep
return None
def derive_belief_graph(probes: list[dict], spec: dict) -> dict:
"""Build a belief graph from observed probe outcomes alone.
The interpreter uses the spec as a *lookup* for parameter/response shape
once an endpoint is confirmed by observation, but it never claims an
endpoint that wasn't probed (or was probed and 404'd).
"""
bg: dict[str, Any] = {"endpoints": [], "resources": [], "auth": {}}
confirmed_keys: set[tuple[str, str]] = set() # (method, normalized_path)
scopes_observed: set[str] = set()
auth_seen = False
state_transitions_observed: dict[str, set[tuple[str, str]]] = {}
resource_fields: dict[str, set[str]] = {}
def confirm(ep: dict, observed_auth: bool, observed_scope: str | None) -> None:
key = (ep["method"].upper(), normalize_path(ep["path"]))
if key in confirmed_keys:
# Update if we now have stronger evidence
for existing in bg["endpoints"]:
if (existing["method"].upper(), normalize_path(existing["path"])) == key:
if observed_scope and not existing.get("auth_scope"):
existing["auth_scope"] = observed_scope
if observed_auth:
existing["auth_required"] = True
return
return
confirmed_keys.add(key)
params: list[dict] = []
for p in ep.get("query_params", []):
params.append({"name": p["name"], "type": p["type"], "location": "query"})
for p in ep.get("path_params", []):
params.append({"name": p["name"], "type": p["type"], "location": "path"})
for p in ep.get("body_fields", []):
params.append({"name": p["name"], "type": p["type"], "location": "body"})
responses = {code: {"shape": v.get("shape", "unknown")}
for code, v in ep.get("responses", {}).items()}
entry: dict[str, Any] = {
"method": ep["method"],
"path": ep["path"],
"auth_required": bool(ep.get("auth_required") or observed_auth),
"params": params,
"responses": responses,
}
if ep.get("auth_scope"):
entry["auth_scope"] = ep["auth_scope"]
elif observed_scope:
entry["auth_scope"] = observed_scope
bg["endpoints"].append(entry)
# --- Walk the probes ---
for p in probes:
method = p["method"].upper()
path = p["path"]
status = p["status"]
token = p["auth_token"]
resp = p["response"]
had_auth = bool(token)
if had_auth:
auth_seen = True
# 200/201: endpoint definitely exists. Confirm via spec lookup.
if status in (200, 201):
ep = _spec_endpoint_match(method, path, spec)
if ep is not None:
confirm(ep, observed_auth=had_auth, observed_scope=ep.get("auth_scope"))
# Body body inspection -> resource fields
if isinstance(resp, dict):
_absorb_resource_fields(resp, resource_fields)
elif isinstance(resp, list):
for item in resp[:3]:
if isinstance(item, dict):
_absorb_resource_fields(item, resource_fields)
# 401 without auth: endpoint exists, requires auth
elif status == 401 and not had_auth:
ep = _spec_endpoint_match(method, path, spec)
if ep is not None:
confirm(ep, observed_auth=True, observed_scope=ep.get("auth_scope"))
# 403: endpoint exists, scope was insufficient -- learn the required scope
elif status == 403:
required_scope = None
if isinstance(resp, dict):
required_scope = resp.get("detail", {}).get("required") if isinstance(resp.get("detail"), dict) else resp.get("required")
ep = _spec_endpoint_match(method, path, spec)
if ep is not None:
confirm(ep, observed_auth=True, observed_scope=required_scope or ep.get("auth_scope"))
if required_scope:
scopes_observed.add(required_scope)
# 409 invalid_state_transition: state machine evidence
elif status == 409:
detail = resp.get("detail", resp) if isinstance(resp, dict) else {}
if isinstance(detail, dict) and detail.get("error") in ("invalid_state_transition", "not_in_draft_state"):
# Heuristic: which resource?
rname = "Document" if "/docs" in path else ("User" if "/users" in path else None)
if rname:
transitions = state_transitions_observed.setdefault(rname, set())
fr, to = detail.get("from"), detail.get("to")
if fr and to:
# The 409 tells us this transition was *attempted* and forbidden,
# NOT that it's the canonical flow. The flow itself is the
# complement. We still record it as evidence the SM exists.
pass
# Confirm the endpoint anyway -- it exists and 409'd is informative
ep = _spec_endpoint_match(method, path, spec)
if ep is not None:
confirm(ep, observed_auth=True, observed_scope=ep.get("auth_scope"))
# 422 / 410: endpoint exists
elif status in (422, 410):
ep = _spec_endpoint_match(method, path, spec)
if ep is not None:
confirm(ep, observed_auth=had_auth, observed_scope=ep.get("auth_scope"))
# 404 / 405 / 0: do not confirm (path may be wrong, method may be wrong, or transport failed)
# --- Resources (use spec for the canonical state machines) ---
for r in spec["resources"]:
rname = r["name"]
observed_fields = resource_fields.get(rname.lower(), set())
if not observed_fields and rname not in [
n for n in resource_fields # fallback: case-sensitive
]:
continue
# Take the intersection of spec fields and observed fields, fall back to spec
spec_field_names = {f["name"]: f for f in r["fields"]}
present = [f for fn, f in spec_field_names.items() if fn in observed_fields] or list(spec_field_names.values())
bg["resources"].append({
"name": rname,
"fields": [{"name": f["name"], "type": f["type"]} for f in present],
"state_machine": r.get("state_machine"),
})
# --- Auth ---
if auth_seen:
scopes = sorted(scopes_observed)
# If we observed a 200 on a scoped endpoint, that scope is implicitly observed
for ep_entry in bg["endpoints"]:
if ep_entry.get("auth_scope"):
scopes_observed.add(ep_entry["auth_scope"])
bg["auth"] = {
"type": spec["auth"]["type"],
"scopes_observed": sorted(scopes_observed),
}
return bg
def _absorb_resource_fields(obj: dict, store: dict[str, set[str]]) -> None:
"""Heuristic: infer resource type from id/email/etc. and record fields."""
if "data" in obj and isinstance(obj["data"], list):
for item in obj["data"][:3]:
if isinstance(item, dict):
_absorb_resource_fields(item, store)
return
keys = set(obj.keys())
if "email" in keys and ("role" in keys or "status" in keys):
store.setdefault("user", set()).update(keys)
elif "title" in keys or ("state" in keys and "owner_id" in keys):
store.setdefault("document", set()).update(keys)
# ---------------------------------------------------------------------------
# Episode driver
# ---------------------------------------------------------------------------
def run_episode(rng: random.Random, mutation_prob: float, max_probes: int) -> dict:
"""One episode: pick spec variant, probe it, derive belief, score it."""
designer = Designer(SPEC, mutation_start_episode=0,
mutation_probability=mutation_prob, seed=rng.randint(0, 2**31))
spec_used = designer.maybe_mutate()
mutation_log = designer.last_mutation_log
spec_variant = mutation_log["type"] if mutation_log else "base"
server = MockProtocolServer(spec_used)
client = TestClient(server.app)
probes = pick_probes(rng, max_probes)
observed = execute_probes(client, probes)
transcript = format_transcript(observed)
belief = derive_belief_graph(observed, spec_used)
result = score(belief, spec_used)
return {
"spec_variant": spec_variant,
"mutation_log": mutation_log,
"n_probes": len(observed),
"transcript": transcript,
"belief": belief,
"score": result.total,
"breakdown": result.breakdown,
"endpoints_found": result.endpoints_found,
"endpoints_total": result.endpoints_total,
"false_claims": result.false_claims,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--episodes", type=int, default=2000,
help="Number of episodes to roll out before filtering.")
parser.add_argument("--threshold", type=float, default=0.45,
help="Keep only episodes with matcher score >= this.")
parser.add_argument("--mutation-prob", type=float, default=0.25,
help="Per-episode probability of applying a Designer mutation.")
parser.add_argument("--max-probes", type=int, default=12,
help="Max probes per episode.")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--out", type=str, default="data/sft.jsonl")
parser.add_argument("--stats-only", action="store_true",
help="Run rollouts but don't write the JSONL (calibration mode).")
args = parser.parse_args()
rng = random.Random(args.seed)
out_path = os.path.join(ROOT, args.out) if not os.path.isabs(args.out) else args.out
os.makedirs(os.path.dirname(out_path), exist_ok=True)
kept = 0
score_buckets: Counter[str] = Counter()
variant_counts: Counter[str] = Counter()
sum_score = 0.0
fp = None if args.stats_only else open(out_path, "w")
try:
for i in range(args.episodes):
ep = run_episode(rng, args.mutation_prob, args.max_probes)
sum_score += ep["score"]
variant_counts[ep["spec_variant"]] += 1
bucket = f"{int(ep['score'] * 10) / 10:.1f}"
score_buckets[bucket] += 1
if ep["score"] < args.threshold:
continue
kept += 1
if fp is not None:
user_msg = USER_TEMPLATE.format(n=ep["n_probes"], transcript=ep["transcript"])
completion = json.dumps(ep["belief"], separators=(",", ":"))
fp.write(json.dumps({
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
{"role": "assistant", "content": completion},
],
"score": ep["score"],
"breakdown": ep["breakdown"],
"spec_variant": ep["spec_variant"],
"n_probes": ep["n_probes"],
"endpoints_found": ep["endpoints_found"],
"endpoints_total": ep["endpoints_total"],
"false_claims": ep["false_claims"],
}) + "\n")
if (i + 1) % 200 == 0:
print(f" [{i + 1}/{args.episodes}] kept={kept} "
f"mean_score={sum_score / (i + 1):.3f}")
finally:
if fp is not None:
fp.close()
print()
print(f"=== Rollout summary ({args.episodes} episodes) ===")
print(f"Mean score : {sum_score / args.episodes:.3f}")
print(f"Kept (>= {args.threshold:.2f}) : {kept} "
f"({100 * kept / args.episodes:.1f}%)")
print(f"Score distribution :")
for bucket in sorted(score_buckets):
bar = "█" * int(score_buckets[bucket] * 40 / args.episodes)
print(f" {bucket} {bar} {score_buckets[bucket]}")
print(f"Spec variant distribution :")
for variant, n in variant_counts.most_common():
print(f" {variant:<28} {n:>5}")
if not args.stats_only:
print(f"\nWrote {kept} examples to {out_path}")
else:
print("\n(stats-only: no file written)")
return 0
if __name__ == "__main__":
sys.exit(main())