nabla-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nabla/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """nabla — call-site harvester CLI (pre-Phase-0)."""
2
+
3
+ __version__ = "0.1.0"
nabla/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """nabla CLI entrypoint — T7. Subcommands: scan, record, profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .contract import Example
11
+ from .profile import build_profile, find_site
12
+ from .recorder import RecordSource, ZeroTrafficError, record
13
+ from .scanner import scan
14
+ from .schema_resolver import resolve_schema
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(prog="nabla", description="Call-site harvester")
19
+ sub = parser.add_subparsers(dest="command", required=True)
20
+
21
+ p_scan = sub.add_parser("scan", help="find + classify call sites")
22
+ p_scan.add_argument("repo", help="path to target repo")
23
+ p_scan.add_argument("--json", action="store_true", help="machine-readable output")
24
+
25
+ p_record = sub.add_parser("record", help="capture (prompt, completion) pairs")
26
+ p_record.add_argument("repo")
27
+ p_record.add_argument("--site", required=True, help="site symbol")
28
+ p_record.add_argument("--samples", help="JSONL sample inputs")
29
+ p_record.add_argument("--logs", help="JSONL of past request/response pairs")
30
+ p_record.add_argument("--proxy-cmd", help="command to run under the recording proxy")
31
+ p_record.add_argument("--out", default="recorded.jsonl")
32
+
33
+ p_profile = sub.add_parser("profile", help="assemble site_profile.json")
34
+ p_profile.add_argument("repo")
35
+ p_profile.add_argument("--site", required=True)
36
+ p_profile.add_argument("--examples", required=True, help="recorded JSONL from `nabla record`")
37
+ p_profile.add_argument("--out", default="site_profile.json")
38
+
39
+ args = parser.parse_args(argv)
40
+ return {"scan": _cmd_scan, "record": _cmd_record, "profile": _cmd_profile}[args.command](args)
41
+
42
+
43
+ def _cmd_scan(args) -> int:
44
+ sites = scan(args.repo)
45
+ rows = []
46
+ for s in sites:
47
+ _, verifiability = resolve_schema(s, args.repo)
48
+ rows.append({**s.to_dict(), "verifiability": verifiability.value})
49
+ if args.json:
50
+ print(json.dumps(rows, indent=2))
51
+ return 0
52
+ print(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS")
53
+ for r in rows:
54
+ print(f"{r['symbol']:<22} {r['file']:<28} {r['kind']:<6} "
55
+ f"{r['verifiability']:<22} {','.join(r['flags']) or '-'}")
56
+ print(f"\n{len(rows)} call sites found.")
57
+ return 0
58
+
59
+
60
+ def _cmd_record(args) -> int:
61
+ sites = scan(args.repo)
62
+ site = find_site(sites, args.site)
63
+ if args.samples:
64
+ source = RecordSource(kind="samples", samples_file=args.samples)
65
+ elif args.logs:
66
+ source = RecordSource(kind="logs", logs_file=args.logs)
67
+ elif args.proxy_cmd:
68
+ source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
69
+ else:
70
+ print("nabla record: one of --samples/--logs/--proxy-cmd is required", file=sys.stderr)
71
+ return 2
72
+ try:
73
+ examples = record(site, source, args.repo)
74
+ except ZeroTrafficError as exc:
75
+ print(f"nabla record: {exc}", file=sys.stderr)
76
+ return 1
77
+ with open(args.out, "w", encoding="utf-8") as f:
78
+ for ex in examples:
79
+ f.write(json.dumps({**ex.to_dict(), "_source_kind": source.kind}) + "\n")
80
+ print(f"nabla record: {len(examples)} examples -> {args.out}")
81
+ return 0
82
+
83
+
84
+ def _cmd_profile(args) -> int:
85
+ from .induce import induce
86
+ from .prompt_recon import reconstruct
87
+ from .schema_resolver import resolve_schema as _resolve
88
+
89
+ sites = scan(args.repo)
90
+ site = find_site(sites, args.site)
91
+
92
+ examples, recorded_from = [], "samples"
93
+ for line in Path(args.examples).read_text(encoding="utf-8").splitlines():
94
+ if not line.strip():
95
+ continue
96
+ d = json.loads(line)
97
+ recorded_from = d.get("_source_kind", recorded_from)
98
+ examples.append(Example(
99
+ input_slots=d.get("input_slots", {}), prompt=d["prompt"],
100
+ completion=d["completion"], tokens_in=d.get("tokens", {}).get("in", 0),
101
+ tokens_out=d.get("tokens", {}).get("out", 0),
102
+ latency_ms=d.get("latency_ms", 0)))
103
+
104
+ template = reconstruct(site, args.repo)
105
+ schema, _ = _resolve(site, args.repo)
106
+ goal = induce(template, schema, examples)
107
+
108
+ profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
109
+ Path(args.out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
110
+ supported = profile["classification"]["supported"]
111
+ print(f"nabla profile: wrote {args.out} "
112
+ f"(site {profile['site_id'][:12]}…, supported={supported}, "
113
+ f"{len(examples)} examples)")
114
+ if len(examples) < 20:
115
+ print(f"nabla profile: WARNING — {len(examples)} examples < 20; "
116
+ "Phase-0-ready profiles need >=20 (cli-plan §5.4)", file=sys.stderr)
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
nabla/contract.py ADDED
@@ -0,0 +1,132 @@
1
+ """Shared contract for all nabla CLI workstreams (T0).
2
+
3
+ Every module codes against these types and nothing else from its siblings.
4
+ RawSite is deliberately serializable and AST-free: downstream consumers
5
+ (prompt reconstruction, schema resolution) re-parse the source file from
6
+ `file` + `span` so workstreams never import each other's internals.
7
+
8
+ Profile shape mirrors cli-plan.md §2 and nabla/schemas/site_profile.schema.json.
9
+ The JSON schema is the source of truth; these dataclasses are the typed
10
+ convenience layer. Bump PROFILE_VERSION on any breaking change to either.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Any
20
+
21
+ PROFILE_VERSION = "0.1"
22
+
23
+
24
+ class Verifiability(str, Enum):
25
+ JSON_SCHEMA = "json_schema" # response_format json_schema — free verifier
26
+ PYDANTIC_PARSE = "pydantic_parse" # beta .parse(response_format=Model)
27
+ TOOL_CALL = "tool_call" # tools=[...] function schemas
28
+ JSON_OBJECT_NO_SCHEMA = "json_object_no_schema" # json_object mode, no schema
29
+ FREE_TEXT = "free_text" # nothing checkable
30
+
31
+
32
+ # Site flags. A site carrying any of UNSUPPORTED_FLAGS is supported=False.
33
+ FLAG_STREAMING = "streaming"
34
+ FLAG_ASYNC = "async"
35
+ FLAG_MULTI_TURN = "multi_turn"
36
+ FLAG_VISION = "vision"
37
+ FLAG_N_GT_1 = "n_gt_1"
38
+ FLAG_WRAPPER_INDIRECT = "wrapper_indirect" # informational, does not unsupport
39
+
40
+ UNSUPPORTED_FLAGS = {FLAG_STREAMING, FLAG_MULTI_TURN, FLAG_VISION, FLAG_N_GT_1}
41
+ SUPPORTED_VERIFIABILITY = {Verifiability.JSON_SCHEMA, Verifiability.PYDANTIC_PARSE}
42
+
43
+
44
+ @dataclass
45
+ class RawSite:
46
+ """Output of the scanner (T1). Serializable; no AST nodes."""
47
+ file: str # repo-relative, forward slashes
48
+ span: tuple[int, int] # 1-indexed inclusive line range of the call expr
49
+ symbol: str # enclosing function/method name, bare, e.g. "enrich" or "run" (no parens — matches manifest)
50
+ kind: str # "create" | "parse"
51
+ flags: list[str] = field(default_factory=list)
52
+ # For a wrapper function's callers: the wrapper's own site info.
53
+ wrapper: RawSiteRef | None = None
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ d = {
57
+ "file": self.file,
58
+ "span": list(self.span),
59
+ "symbol": self.symbol,
60
+ "kind": self.kind,
61
+ "flags": sorted(self.flags),
62
+ }
63
+ if self.wrapper:
64
+ d["wrapper"] = {"file": self.wrapper.file, "span": list(self.wrapper.span),
65
+ "symbol": self.wrapper.symbol}
66
+ return d
67
+
68
+
69
+ @dataclass
70
+ class RawSiteRef:
71
+ file: str
72
+ span: tuple[int, int]
73
+ symbol: str
74
+
75
+
76
+ @dataclass
77
+ class Slot:
78
+ name: str
79
+ observed_values: list[str] = field(default_factory=list)
80
+ inferred_type: str = "unknown"
81
+
82
+
83
+ @dataclass
84
+ class PromptTemplate:
85
+ """Output of prompt reconstruction (T2)."""
86
+ template: str # user-message template with {slot} markers
87
+ slots: list[Slot] = field(default_factory=list)
88
+ system_prompt: str | None = None
89
+ embedded_few_shot: list[dict[str, str]] = field(default_factory=list)
90
+ partial: bool = False # True when fragments were unresolvable
91
+ # Unresolvable fragments appear in template as {UNRESOLVED_n} and are
92
+ # listed in slots with inferred_type="unresolved".
93
+
94
+
95
+ @dataclass
96
+ class Example:
97
+ """One recorded (prompt, completion) pair (T4)."""
98
+ input_slots: dict[str, str]
99
+ prompt: str
100
+ completion: str
101
+ tokens_in: int = 0
102
+ tokens_out: int = 0
103
+ latency_ms: int = 0
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "input_slots": self.input_slots,
108
+ "prompt": self.prompt,
109
+ "completion": self.completion,
110
+ "tokens": {"in": self.tokens_in, "out": self.tokens_out},
111
+ "latency_ms": self.latency_ms,
112
+ }
113
+
114
+
115
+ @dataclass
116
+ class GoalBlock:
117
+ """Output of goal inference / difficulty induction (T6)."""
118
+ description: str
119
+ constraints: list[str] = field(default_factory=list)
120
+ difficulty_dimensions: list[dict[str, Any]] = field(default_factory=list)
121
+ # each dimension: {"name": str, "levels": [str], "evidence": str}
122
+
123
+
124
+ def compute_site_id(template: str, schema: dict[str, Any] | None) -> str:
125
+ """Stable site identity: hash of normalized template + schema, not file:line."""
126
+ norm_template = " ".join(template.split())
127
+ norm_schema = json.dumps(schema, sort_keys=True, separators=(",", ":")) if schema else ""
128
+ return hashlib.sha256(f"{norm_template}\x00{norm_schema}".encode()).hexdigest()
129
+
130
+
131
+ def is_supported(verifiability: Verifiability, flags: list[str]) -> bool:
132
+ return verifiability in SUPPORTED_VERIFIABILITY and not (set(flags) & UNSUPPORTED_FLAGS)
nabla/induce.py ADDED
@@ -0,0 +1,176 @@
1
+ """T6 — goal inference + difficulty-dimension induction (the one LLM step).
2
+
3
+ Runs on Gemini's free tier via its OpenAI-compatible endpoint. The client is
4
+ injectable so tests run fully offline; real responses are cached to disk so
5
+ re-runs are deterministic and cheap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .contract import Example, GoalBlock, PromptTemplate
18
+
19
+ # Defaults target Gemini's free tier; every piece is env-overridable so the
20
+ # induction provider can be swapped without code changes (e.g. Groq when a
21
+ # daily quota is exhausted): NABLA_INDUCE_BASE_URL, NABLA_INDUCE_API_KEY_ENV
22
+ # (name of the env var holding the key, read from env or cli/.env — never
23
+ # hardcode keys here), NABLA_INDUCE_MODEL. Re-align the default with Phase 0's
24
+ # frontier choice when it lands.
25
+ INDUCE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
26
+ INDUCE_API_KEY_ENV = "GEMINI_API_KEY"
27
+ DEFAULT_INDUCE_MODEL = "gemini-3.5-flash"
28
+
29
+ _SYSTEM = """You analyze a harvested LLM call site and produce a task profile for a downstream
30
+ training-data generator. You are given the call site's prompt template, its output
31
+ JSON schema, and real recorded (prompt, completion) examples.
32
+
33
+ Respond with ONLY a JSON object, no markdown fences, shaped exactly:
34
+ {
35
+ "description": "<what this call site's task is, one or two sentences>",
36
+ "constraints": ["<implicit rule mined from the system prompt or examples>", ...],
37
+ "difficulty_dimensions": [
38
+ {"name": "<snake_case dimension>", "levels": ["<easy>", "...", "<hard>"],
39
+ "evidence": "<which examples/template features show this dimension varies>"},
40
+ ...
41
+ ]
42
+ }
43
+
44
+ Rules:
45
+ - description states the task, not the mechanism ("extracts X from Y into schema Z").
46
+ - constraints are rules the completion must follow that the schema alone does not
47
+ enforce (null-discipline, normalization, language, tone).
48
+ - difficulty_dimensions are the axes along which REAL inputs to this site vary in
49
+ ways that plausibly make the task harder. 3-6 dimensions, each with 2+ ordered
50
+ levels (easiest first) and evidence citing the examples or template. Do not invent
51
+ dimensions the examples give no evidence for."""
52
+
53
+
54
+ class InduceError(RuntimeError):
55
+ """The induction model returned something unusable."""
56
+
57
+
58
+ def induce(template: PromptTemplate, schema: dict[str, Any] | None,
59
+ examples: list[Example], *, model: str | None = None,
60
+ cache_dir: str | None = None, client: Any = None) -> GoalBlock:
61
+ """Frontier-model pass producing goal description, constraints, and
62
+ difficulty dimensions with cited evidence.
63
+
64
+ `client` is any OpenAI-compatible client (injectable for tests); when
65
+ None, one is built for Gemini from GEMINI_API_KEY. Responses are cached
66
+ in `cache_dir` (default .nabla_cache next to cwd) keyed by inputs+model,
67
+ so repeat runs are offline and deterministic.
68
+ """
69
+ model = model or os.environ.get("NABLA_INDUCE_MODEL", DEFAULT_INDUCE_MODEL)
70
+ user_payload = _build_user_payload(template, schema, examples)
71
+
72
+ cache_path = None
73
+ if cache_dir is not False: # allow cache_dir=False to disable entirely
74
+ cache_root = Path(cache_dir or ".nabla_cache")
75
+ key = hashlib.sha256(json.dumps([model, _SYSTEM, user_payload],
76
+ sort_keys=True).encode()).hexdigest()
77
+ cache_path = cache_root / f"induce-{key}.json"
78
+ if cache_path.exists():
79
+ return _parse_goal(cache_path.read_text(encoding="utf-8"))
80
+
81
+ if client is None:
82
+ client = _default_client()
83
+
84
+ response = client.chat.completions.create(
85
+ model=model,
86
+ messages=[
87
+ {"role": "system", "content": _SYSTEM},
88
+ {"role": "user", "content": user_payload},
89
+ ],
90
+ temperature=0.2,
91
+ )
92
+ raw = response.choices[0].message.content or ""
93
+ goal = _parse_goal(raw) # validate before caching — never cache garbage
94
+
95
+ if cache_path is not None:
96
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
97
+ cache_path.write_text(raw, encoding="utf-8")
98
+ return goal
99
+
100
+
101
+ def _default_client():
102
+ from openai import OpenAI
103
+
104
+ base_url = os.environ.get("NABLA_INDUCE_BASE_URL", INDUCE_BASE_URL)
105
+ key_env = os.environ.get("NABLA_INDUCE_API_KEY_ENV", INDUCE_API_KEY_ENV)
106
+ api_key = os.environ.get(key_env) or _read_dotenv_key(key_env)
107
+ if not api_key:
108
+ raise InduceError(
109
+ f"{key_env} not set (env or cli/.env). "
110
+ "T6 induction needs an API key for its OpenAI-compatible provider."
111
+ )
112
+ return OpenAI(base_url=base_url, api_key=api_key)
113
+
114
+
115
+ def _read_dotenv_key(name: str) -> str | None:
116
+ # Minimal .env reader: look next to this package's parent (cli/.env), then cwd.
117
+ for candidate in (Path(__file__).resolve().parents[1] / ".env", Path(".env")):
118
+ try:
119
+ for line in candidate.read_text(encoding="utf-8").splitlines():
120
+ line = line.strip()
121
+ if line.startswith(f"{name}=") and not line.startswith("#"):
122
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
123
+ except OSError:
124
+ continue
125
+ return None
126
+
127
+
128
+ def _build_user_payload(template: PromptTemplate, schema: dict[str, Any] | None,
129
+ examples: list[Example]) -> str:
130
+ parts = ["## Prompt template\n" + template.template]
131
+ if template.system_prompt:
132
+ parts.append("## System prompt\n" + template.system_prompt)
133
+ parts.append("## Slots\n" + json.dumps(
134
+ [{"name": s.name, "inferred_type": s.inferred_type} for s in template.slots]))
135
+ parts.append("## Output schema\n" +
136
+ (json.dumps(schema, indent=2) if schema else "(none)"))
137
+ shown = examples[:20]
138
+ ex_lines = [
139
+ f"### Example {i + 1}\nINPUT SLOTS: {json.dumps(e.input_slots)}\n"
140
+ f"PROMPT: {e.prompt}\nCOMPLETION: {e.completion}"
141
+ for i, e in enumerate(shown)
142
+ ]
143
+ parts.append(f"## Recorded examples ({len(shown)} of {len(examples)})\n" +
144
+ "\n".join(ex_lines))
145
+ return "\n\n".join(parts)
146
+
147
+
148
+ def _parse_goal(raw: str) -> GoalBlock:
149
+ text = raw.strip()
150
+ fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
151
+ if fence:
152
+ text = fence.group(1)
153
+ try:
154
+ data = json.loads(text)
155
+ except json.JSONDecodeError as exc:
156
+ raise InduceError(f"induction model did not return JSON: {exc}\n{raw[:500]}") from exc
157
+
158
+ description = data.get("description")
159
+ constraints = data.get("constraints", [])
160
+ dims = data.get("difficulty_dimensions", [])
161
+ if not isinstance(description, str) or not description.strip():
162
+ raise InduceError("induction result missing 'description'")
163
+ if not isinstance(constraints, list) or not all(isinstance(c, str) for c in constraints):
164
+ raise InduceError("induction result 'constraints' must be a list of strings")
165
+ clean_dims: list[dict[str, Any]] = []
166
+ for d in dims if isinstance(dims, list) else []:
167
+ if (isinstance(d, dict) and isinstance(d.get("name"), str)
168
+ and isinstance(d.get("levels"), list) and len(d["levels"]) >= 2
169
+ and all(isinstance(level, str) for level in d["levels"])
170
+ and isinstance(d.get("evidence"), str)):
171
+ clean_dims.append({"name": d["name"], "levels": d["levels"],
172
+ "evidence": d["evidence"]})
173
+ if not clean_dims:
174
+ raise InduceError("induction result has no well-formed difficulty_dimensions")
175
+ return GoalBlock(description=description.strip(), constraints=constraints,
176
+ difficulty_dimensions=clean_dims)
nabla/profile.py ADDED
@@ -0,0 +1,138 @@
1
+ """T7 — site profile assembly: compose T1–T6 outputs into a validated
2
+ site_profile.json (the Phase 0 handoff artifact).
3
+
4
+ Also owns sampling-parameter extraction (model/temperature/max_tokens/...),
5
+ which is plain AST kwarg reading and belongs to assembly rather than any
6
+ single workstream.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import json
13
+ from importlib import resources
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .contract import (
18
+ PROFILE_VERSION,
19
+ Example,
20
+ GoalBlock,
21
+ PromptTemplate,
22
+ RawSite,
23
+ Verifiability,
24
+ compute_site_id,
25
+ is_supported,
26
+ )
27
+ from .prompt_recon import reconstruct
28
+ from .recorder import baseline_stats
29
+ from .schema_resolver import resolve_schema
30
+
31
+ # Packaged with the module (nabla/schemas/) so installed wheels find it too.
32
+ _SCHEMA_RESOURCE = resources.files("nabla").joinpath("schemas/site_profile.schema.json")
33
+
34
+
35
+ def extract_sampling(site: RawSite, repo_path: str) -> dict[str, Any]:
36
+ """Read literal sampling kwargs off the SDK call (wrapper's call for
37
+ wrapper-indirect sites). Non-literal values are reported as null."""
38
+ ref = site.wrapper or site
39
+ path = Path(repo_path) / ref.file
40
+ sampling: dict[str, Any] = {"model": "unknown", "temperature": None,
41
+ "max_tokens": None, "top_p": None, "stop": None}
42
+ try:
43
+ tree = ast.parse(path.read_text(encoding="utf-8"))
44
+ except (OSError, SyntaxError):
45
+ return sampling
46
+ for node in ast.walk(tree):
47
+ if (isinstance(node, ast.Call)
48
+ and node.lineno == ref.span[0]
49
+ and getattr(node, "end_lineno", node.lineno) == ref.span[1]):
50
+ for kw in node.keywords:
51
+ if kw.arg in sampling and isinstance(kw.value, ast.Constant):
52
+ sampling[kw.arg] = kw.value.value
53
+ break
54
+ if sampling["model"] is None or sampling["model"] == "unknown":
55
+ sampling["model"] = "unknown"
56
+ return sampling
57
+
58
+
59
+ def build_profile(site: RawSite, repo_path: str, examples: list[Example],
60
+ goal: GoalBlock, recorded_from: str = "samples") -> dict[str, Any]:
61
+ """Assemble the complete profile dict. Raises jsonschema.ValidationError
62
+ if the result does not conform to the frozen schema."""
63
+ template: PromptTemplate = reconstruct(site, repo_path)
64
+ schema, verifiability = resolve_schema(site, repo_path)
65
+ sampling = extract_sampling(site, repo_path)
66
+
67
+ # Fold observed slot values from recorded examples into the template slots.
68
+ observed: dict[str, list[str]] = {}
69
+ for ex in examples:
70
+ for name, value in ex.input_slots.items():
71
+ vals = observed.setdefault(name, [])
72
+ if str(value) not in vals and len(vals) < 5:
73
+ vals.append(str(value))
74
+
75
+ profile = {
76
+ "profile_version": PROFILE_VERSION,
77
+ "site_id": compute_site_id(template.template, schema),
78
+ "source": {"file": site.file, "span": list(site.span), "symbol": site.symbol},
79
+ "classification": {
80
+ "supported": is_supported(verifiability, site.flags),
81
+ "verifiability": verifiability.value,
82
+ "flags": sorted(site.flags),
83
+ },
84
+ "prompt": {
85
+ "template": template.template,
86
+ "partial": template.partial,
87
+ "slots": [
88
+ {"name": s.name,
89
+ "observed_values": observed.get(s.name, s.observed_values),
90
+ "inferred_type": s.inferred_type}
91
+ for s in template.slots
92
+ ],
93
+ "system_prompt": template.system_prompt,
94
+ "embedded_few_shot": template.embedded_few_shot,
95
+ },
96
+ "output_schema": schema,
97
+ "sampling": sampling,
98
+ "examples": [e.to_dict() for e in examples],
99
+ "goal": {
100
+ "description": goal.description,
101
+ "constraints": goal.constraints,
102
+ "difficulty_dimensions": goal.difficulty_dimensions,
103
+ },
104
+ "baseline": _baseline_block(examples, sampling["model"]),
105
+ "provenance": {"recorded_from": recorded_from if examples else "none"},
106
+ }
107
+ validate_profile(profile)
108
+ return profile
109
+
110
+
111
+ def _baseline_block(examples: list[Example], model: str) -> dict[str, Any]:
112
+ if not examples:
113
+ return {"cost_per_call_usd": 0.0, "p50_latency_ms": 0, "calls_observed": 0}
114
+ stats = baseline_stats(examples, model)
115
+ return {
116
+ "cost_per_call_usd": float(stats.get("cost_per_call_usd", 0.0)),
117
+ "p50_latency_ms": int(stats.get("p50_latency_ms", 0)),
118
+ "calls_observed": int(stats.get("calls_observed", len(examples))),
119
+ }
120
+
121
+
122
+ def validate_profile(profile: dict[str, Any]) -> None:
123
+ import jsonschema
124
+
125
+ schema = json.loads(_SCHEMA_RESOURCE.read_text(encoding="utf-8"))
126
+ jsonschema.validate(profile, schema)
127
+
128
+
129
+ def find_site(sites: list[RawSite], selector: str) -> RawSite:
130
+ """Match a site by symbol or site-id-ish prefix; error lists candidates."""
131
+ matches = [s for s in sites if s.symbol == selector]
132
+ if len(matches) == 1:
133
+ return matches[0]
134
+ if not matches:
135
+ available = ", ".join(sorted({s.symbol for s in sites}))
136
+ raise SystemExit(f"nabla: no site named {selector!r}. Sites found: {available}")
137
+ files = ", ".join(s.file for s in matches)
138
+ raise SystemExit(f"nabla: {selector!r} is ambiguous across: {files} — split not yet supported")