skillproof 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.
Files changed (43) hide show
  1. skillproof/__init__.py +3 -0
  2. skillproof/__main__.py +3 -0
  3. skillproof/assertions/__init__.py +62 -0
  4. skillproof/assertions/base.py +44 -0
  5. skillproof/assertions/builtin.py +176 -0
  6. skillproof/assertions/llm_judge.py +24 -0
  7. skillproof/cli.py +313 -0
  8. skillproof/config.py +188 -0
  9. skillproof/engine.py +268 -0
  10. skillproof/errors.py +72 -0
  11. skillproof/estimate.py +83 -0
  12. skillproof/judges/__init__.py +43 -0
  13. skillproof/judges/anthropic_judge.py +59 -0
  14. skillproof/judges/base.py +69 -0
  15. skillproof/judges/mock.py +21 -0
  16. skillproof/judges/openai_judge.py +58 -0
  17. skillproof/parsers/__init__.py +6 -0
  18. skillproof/parsers/claude_code.py +144 -0
  19. skillproof/parsers/codex.py +112 -0
  20. skillproof/report/__init__.py +34 -0
  21. skillproof/report/badge.py +60 -0
  22. skillproof/report/format.py +75 -0
  23. skillproof/report/html_report.py +296 -0
  24. skillproof/report/json_report.py +9 -0
  25. skillproof/report/markdown.py +78 -0
  26. skillproof/report/summary.py +216 -0
  27. skillproof/report/terminal.py +89 -0
  28. skillproof/results.py +88 -0
  29. skillproof/runners/__init__.py +33 -0
  30. skillproof/runners/base.py +102 -0
  31. skillproof/runners/claude_code.py +117 -0
  32. skillproof/runners/codex.py +133 -0
  33. skillproof/runners/mock.py +91 -0
  34. skillproof/runners/openai_compat.py +238 -0
  35. skillproof/sandbox.py +85 -0
  36. skillproof/scaffold.py +153 -0
  37. skillproof/skill.py +97 -0
  38. skillproof/stats.py +228 -0
  39. skillproof-0.1.0.dist-info/METADATA +262 -0
  40. skillproof-0.1.0.dist-info/RECORD +43 -0
  41. skillproof-0.1.0.dist-info/WHEEL +4 -0
  42. skillproof-0.1.0.dist-info/entry_points.txt +2 -0
  43. skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,59 @@
1
+ """Claude as a judge (Anthropic Messages API, structured JSON output)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from skillproof.errors import JudgeNotReady
8
+ from skillproof.judges.base import (
9
+ VERDICT_SCHEMA,
10
+ Judge,
11
+ JudgeUnavailable,
12
+ Verdict,
13
+ judge_prompt,
14
+ parse_verdict,
15
+ )
16
+
17
+ DEFAULT_MODEL = "claude-opus-5-5"
18
+
19
+
20
+ class AnthropicJudge(Judge):
21
+ name = "anthropic"
22
+ vendor = "anthropic"
23
+
24
+ def __init__(
25
+ self, model: str | None = None, api_key_env: str | None = None, base_url: str | None = None
26
+ ) -> None:
27
+ import anthropic
28
+
29
+ self.model = model or DEFAULT_MODEL
30
+ api_key = None
31
+ if api_key_env:
32
+ api_key = os.environ.get(api_key_env)
33
+ if not api_key:
34
+ raise JudgeNotReady(
35
+ f"Judge API key env var {api_key_env} is not set.",
36
+ hint=f"export {api_key_env}=... or change `judge.api_key_env`.",
37
+ )
38
+ self.client = anthropic.AsyncAnthropic(api_key=api_key, base_url=base_url)
39
+
40
+ async def grade(self, rubric: str, response: str) -> Verdict:
41
+ import anthropic
42
+
43
+ try:
44
+ message = await self.client.messages.create(
45
+ model=self.model,
46
+ max_tokens=16000,
47
+ messages=[{"role": "user", "content": judge_prompt(rubric, response)}],
48
+ output_config={"format": {"type": "json_schema", "schema": VERDICT_SCHEMA}},
49
+ )
50
+ except anthropic.APIError as exc:
51
+ raise JudgeUnavailable(f"judge API error: {exc}") from exc
52
+ except TypeError as exc: # raised by the SDK when no credentials are found
53
+ raise JudgeUnavailable(
54
+ "no Anthropic credentials for the judge: export ANTHROPIC_API_KEY"
55
+ ) from exc
56
+ if message.stop_reason == "refusal":
57
+ raise JudgeUnavailable("judge refused to grade this answer")
58
+ text = next((b.text for b in message.content if b.type == "text"), "")
59
+ return parse_verdict(text)
@@ -0,0 +1,69 @@
1
+ """The Judge interface used by `llm_judge` assertions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class Verdict(BaseModel):
12
+ passed: bool
13
+ reason: str
14
+
15
+
16
+ class JudgeUnavailable(Exception):
17
+ """The judge itself failed (API error, refusal). The run is marked errored,
18
+ not failed, so judge outages don't skew pass rates."""
19
+
20
+
21
+ class Judge(ABC):
22
+ name: str
23
+ vendor: str
24
+ model: str | None = None
25
+
26
+ @abstractmethod
27
+ async def grade(self, rubric: str, response: str) -> Verdict: ...
28
+
29
+ @property
30
+ def label(self) -> str:
31
+ return f"{self.name}:{self.model}" if self.model else self.name
32
+
33
+
34
+ JUDGE_PROMPT = """\
35
+ You are grading an AI agent's final answer against a rubric.
36
+ Be strict: the answer passes only if it clearly satisfies the rubric.
37
+ Judge only what is in the answer; do not give credit for intent.
38
+
39
+ <rubric>
40
+ {rubric}
41
+ </rubric>
42
+
43
+ <answer>
44
+ {response}
45
+ </answer>
46
+
47
+ Respond with JSON: {{"passed": true or false, "reason": "<one sentence>"}}
48
+ """
49
+
50
+ VERDICT_SCHEMA = {
51
+ "type": "object",
52
+ "properties": {
53
+ "passed": {"type": "boolean"},
54
+ "reason": {"type": "string"},
55
+ },
56
+ "required": ["passed", "reason"],
57
+ "additionalProperties": False,
58
+ }
59
+
60
+
61
+ def judge_prompt(rubric: str, response: str) -> str:
62
+ return JUDGE_PROMPT.format(rubric=rubric.strip(), response=response.strip() or "(empty)")
63
+
64
+
65
+ def parse_verdict(text: str) -> Verdict:
66
+ try:
67
+ return Verdict.model_validate(json.loads(text))
68
+ except (json.JSONDecodeError, ValueError) as exc:
69
+ raise JudgeUnavailable(f"judge returned an unreadable verdict: {text[:200]!r}") from exc
@@ -0,0 +1,21 @@
1
+ """A judge for tests: passes when every `pass_if` phrase appears in the answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from skillproof.judges.base import Judge, Verdict
6
+
7
+
8
+ class MockJudge(Judge):
9
+ name = "mock"
10
+ vendor = "mock"
11
+
12
+ def __init__(self, pass_if: list[str] | None = None) -> None:
13
+ self.pass_if = pass_if or []
14
+ self.calls: list[tuple[str, str]] = []
15
+
16
+ async def grade(self, rubric: str, response: str) -> Verdict:
17
+ self.calls.append((rubric, response))
18
+ missing = [p for p in self.pass_if if p.lower() not in response.lower()]
19
+ if missing:
20
+ return Verdict(passed=False, reason=f"missing: {', '.join(missing)}")
21
+ return Verdict(passed=True, reason="mock judge: all phrases present")
@@ -0,0 +1,58 @@
1
+ """An OpenAI model as a judge (Responses API, structured JSON output)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from skillproof.errors import JudgeNotReady
8
+ from skillproof.judges.base import (
9
+ VERDICT_SCHEMA,
10
+ Judge,
11
+ JudgeUnavailable,
12
+ Verdict,
13
+ judge_prompt,
14
+ parse_verdict,
15
+ )
16
+
17
+ DEFAULT_MODEL = "gpt-6.1-sol"
18
+
19
+
20
+ class OpenAIJudge(Judge):
21
+ name = "openai"
22
+ vendor = "openai"
23
+
24
+ def __init__(
25
+ self, model: str | None = None, api_key_env: str | None = None, base_url: str | None = None
26
+ ) -> None:
27
+ import openai
28
+
29
+ self.model = model or DEFAULT_MODEL
30
+ key_env = api_key_env or "OPENAI_API_KEY"
31
+ api_key = os.environ.get(key_env)
32
+ if not api_key:
33
+ raise JudgeNotReady(
34
+ f"{key_env} is not set, and llm_judge needs it.",
35
+ hint=f"export {key_env}=..., or set `judge: {{provider: anthropic}}`.",
36
+ )
37
+ self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
38
+
39
+ async def grade(self, rubric: str, response: str) -> Verdict:
40
+ import openai
41
+
42
+ try:
43
+ result = await self.client.responses.create(
44
+ model=self.model,
45
+ input=judge_prompt(rubric, response),
46
+ text={
47
+ "format": {
48
+ "type": "json_schema",
49
+ "name": "verdict",
50
+ "strict": True,
51
+ "schema": VERDICT_SCHEMA,
52
+ }
53
+ },
54
+ store=False,
55
+ )
56
+ except openai.APIError as exc:
57
+ raise JudgeUnavailable(f"judge API error: {exc}") from exc
58
+ return parse_verdict(result.output_text)
@@ -0,0 +1,6 @@
1
+ """Transcript parsers: turn an agent's raw event log into an AgentRun.
2
+
3
+ Agents change their output formats often, so each parser is a set of small
4
+ pure functions over plain dicts (the same dicts we save as the transcript).
5
+ Tests feed them recorded transcripts; no API calls needed.
6
+ """
@@ -0,0 +1,144 @@
1
+ """Parse a Claude Agent SDK message stream (as dicts) into an AgentRun.
2
+
3
+ Each message is saved as a dict with a `_type` key naming the SDK class
4
+ (`SystemMessage`, `AssistantMessage`, `UserMessage`, `ResultMessage`, ...).
5
+ Content blocks get the same treatment (`TextBlock`, `ToolUseBlock`, ...).
6
+
7
+ A skill invocation shows up as a `Skill` tool call:
8
+ {"_type": "ToolUseBlock", "name": "Skill", "input": {"skill": "my-skill"}}
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ from typing import Any
15
+
16
+ from skillproof.runners.base import AgentRun, ToolCall, Usage
17
+
18
+ SKILL_TOOL = "Skill"
19
+ DROPPED_FIELDS = {"signature"} # opaque thinking signatures: large and useless for debugging
20
+
21
+
22
+ def to_dict(obj: Any) -> Any:
23
+ """Convert SDK dataclasses (recursively) into JSON-friendly dicts."""
24
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
25
+ out: dict[str, Any] = {"_type": type(obj).__name__}
26
+ for field in dataclasses.fields(obj):
27
+ if field.name not in DROPPED_FIELDS:
28
+ out[field.name] = to_dict(getattr(obj, field.name))
29
+ return out
30
+ if isinstance(obj, dict):
31
+ return {str(k): to_dict(v) for k, v in obj.items()}
32
+ if isinstance(obj, (list, tuple)):
33
+ return [to_dict(v) for v in obj]
34
+ if obj is None or isinstance(obj, (str, int, float, bool)):
35
+ return obj
36
+ return str(obj)
37
+
38
+
39
+ def _blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
40
+ content = message.get("content")
41
+ return content if isinstance(content, list) else []
42
+
43
+
44
+ def _result_text(content: Any) -> str:
45
+ if isinstance(content, str):
46
+ return content
47
+ if isinstance(content, list):
48
+ return "\n".join(str(c.get("text", "")) for c in content if isinstance(c, dict))
49
+ return ""
50
+
51
+
52
+ def tool_calls(transcript: list[dict[str, Any]]) -> list[ToolCall]:
53
+ calls: dict[str, ToolCall] = {}
54
+ for message in transcript:
55
+ for block in _blocks(message):
56
+ if block.get("_type") == "ToolUseBlock":
57
+ calls[block["id"]] = ToolCall(name=block["name"], input=block.get("input") or {})
58
+ elif block.get("_type") == "ToolResultBlock" and block.get("tool_use_id") in calls:
59
+ calls[block["tool_use_id"]].output = _result_text(block.get("content"))
60
+ return list(calls.values())
61
+
62
+
63
+ def is_skill_call(call: ToolCall, skill_name: str) -> bool:
64
+ """A Skill tool call naming this skill, or a direct read of its SKILL.md."""
65
+ if call.name == SKILL_TOOL:
66
+ invoked = str(call.input.get("skill", ""))
67
+ return invoked == skill_name or invoked.endswith(f":{skill_name}")
68
+ marker = f"skills/{skill_name}/SKILL.md"
69
+ return any(isinstance(v, str) and marker in v for v in call.input.values())
70
+
71
+
72
+ def init_data(transcript: list[dict[str, Any]]) -> dict[str, Any]:
73
+ for message in transcript:
74
+ if message.get("_type") == "SystemMessage" and message.get("subtype") == "init":
75
+ return message.get("data") or {}
76
+ return {}
77
+
78
+
79
+ def final_text(transcript: list[dict[str, Any]], result: dict[str, Any] | None) -> str:
80
+ if result and result.get("result"):
81
+ return str(result["result"])
82
+ for message in reversed(transcript): # fall back to the last assistant text
83
+ if message.get("_type") == "AssistantMessage" and not message.get("parent_tool_use_id"):
84
+ text = "".join(b.get("text", "") for b in _blocks(message) if b["_type"] == "TextBlock")
85
+ if text:
86
+ return text
87
+ return ""
88
+
89
+
90
+ def parse_usage(result: dict[str, Any] | None) -> Usage:
91
+ usage = (result or {}).get("usage") or {}
92
+ return Usage(
93
+ input_tokens=usage.get("input_tokens") or 0,
94
+ output_tokens=usage.get("output_tokens") or 0,
95
+ cache_read_tokens=usage.get("cache_read_input_tokens") or 0,
96
+ cache_write_tokens=usage.get("cache_creation_input_tokens") or 0,
97
+ cost_usd=(result or {}).get("total_cost_usd"),
98
+ )
99
+
100
+
101
+ def parse_error(result: dict[str, Any] | None) -> tuple[str | None, list[str]]:
102
+ """Split failures into infrastructure errors and agent-side warnings."""
103
+ if result is None:
104
+ return "Claude Code ended without a result message", []
105
+ if not result.get("is_error"):
106
+ return None, []
107
+ subtype = result.get("subtype", "")
108
+ details = "; ".join(result.get("errors") or []) or str(result.get("result") or "")
109
+ if result.get("api_error_status"):
110
+ return f"API error {result['api_error_status']}: {details}".strip(": "), []
111
+ if subtype in ("error_max_turns", "error_max_budget_usd"):
112
+ # The agent ran out of its allowance: a real outcome, not an infrastructure error.
113
+ return None, [f"stopped early: {subtype}"]
114
+ return f"Claude Code error ({subtype}): {details}".strip(": "), []
115
+
116
+
117
+ def parse_transcript(transcript: list[dict[str, Any]], skill_name: str | None) -> AgentRun:
118
+ result = next((m for m in reversed(transcript) if m.get("_type") == "ResultMessage"), None)
119
+ calls = tool_calls(transcript)
120
+ error, warnings = parse_error(result)
121
+ init = init_data(transcript)
122
+
123
+ triggered: bool | None = None
124
+ if skill_name is not None:
125
+ triggered = any(is_skill_call(c, skill_name) for c in calls)
126
+ listed = init.get("skills")
127
+ if isinstance(listed, list) and skill_name not in listed:
128
+ warnings.append(
129
+ f"skill {skill_name!r} was not listed at startup; "
130
+ "check SKILL.md frontmatter (or `user-invocable: false`)"
131
+ )
132
+
133
+ return AgentRun(
134
+ final_text=final_text(transcript, result),
135
+ transcript=transcript,
136
+ tool_calls=calls,
137
+ usage=parse_usage(result),
138
+ duration_s=((result or {}).get("duration_ms") or 0) / 1000,
139
+ num_turns=(result or {}).get("num_turns"),
140
+ model=init.get("model"),
141
+ triggered=triggered,
142
+ error=error,
143
+ warnings=warnings,
144
+ )
@@ -0,0 +1,112 @@
1
+ """Parse `codex exec --json` events (JSONL, one dict per line) into an AgentRun.
2
+
3
+ Event types: thread.started, turn.started, turn.completed {usage},
4
+ turn.failed {error}, item.started/updated/completed {item}, error {message}.
5
+ Item types we use: agent_message {text}, command_execution {command,
6
+ aggregated_output, exit_code}, file_change {changes}, mcp_tool_call, error.
7
+
8
+ Codex has no dedicated skill tool: the model reads SKILL.md (or runs a script
9
+ from the skill folder) with a shell command, so a skill counts as triggered
10
+ when a command touches `skills/<name>/`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ from skillproof.runners.base import AgentRun, ToolCall, Usage
19
+
20
+
21
+ def parse_jsonl(text: str) -> list[dict[str, Any]]:
22
+ events = []
23
+ for line in text.splitlines():
24
+ line = line.strip()
25
+ if not line:
26
+ continue
27
+ try:
28
+ event = json.loads(line)
29
+ except json.JSONDecodeError:
30
+ continue # not an event (stray log line)
31
+ if isinstance(event, dict):
32
+ events.append(event)
33
+ return events
34
+
35
+
36
+ def completed_items(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
37
+ return [e["item"] for e in events if e.get("type") == "item.completed" and "item" in e]
38
+
39
+
40
+ def to_tool_call(item: dict[str, Any]) -> ToolCall | None:
41
+ kind = item.get("type")
42
+ if kind == "command_execution":
43
+ return ToolCall(
44
+ name="command_execution",
45
+ input={"command": item.get("command", ""), "exit_code": item.get("exit_code")},
46
+ output=item.get("aggregated_output"),
47
+ )
48
+ if kind == "file_change":
49
+ return ToolCall(name="file_change", input={"changes": item.get("changes", [])})
50
+ if kind == "mcp_tool_call":
51
+ return ToolCall(
52
+ name=f"mcp:{item.get('server')}/{item.get('tool')}",
53
+ input={"arguments": item.get("arguments")},
54
+ output=json.dumps(item.get("result")) if item.get("result") else None,
55
+ )
56
+ if kind == "web_search":
57
+ return ToolCall(name="web_search", input={"query": item.get("query")})
58
+ return None
59
+
60
+
61
+ def is_skill_call(call: ToolCall, skill_name: str) -> bool:
62
+ return call.name == "command_execution" and f"skills/{skill_name}/" in str(
63
+ call.input.get("command", "")
64
+ )
65
+
66
+
67
+ def parse_usage(events: list[dict[str, Any]]) -> Usage:
68
+ """Sum usage over all turns. Codex's input_tokens include cached tokens."""
69
+ usage = Usage()
70
+ for event in events:
71
+ if event.get("type") != "turn.completed":
72
+ continue
73
+ u = event.get("usage") or {}
74
+ cached = u.get("cached_input_tokens") or 0
75
+ written = u.get("cache_write_input_tokens") or 0
76
+ usage.input_tokens += max(0, (u.get("input_tokens") or 0) - cached - written)
77
+ usage.cache_read_tokens += cached
78
+ usage.cache_write_tokens += written
79
+ usage.output_tokens += u.get("output_tokens") or 0
80
+ return usage
81
+
82
+
83
+ def parse_events(events: list[dict[str, Any]], skill_name: str | None) -> AgentRun:
84
+ items = completed_items(events)
85
+ calls = [c for c in (to_tool_call(i) for i in items) if c is not None]
86
+ messages = [i.get("text", "") for i in items if i.get("type") == "agent_message"]
87
+
88
+ error = None
89
+ failed = [e for e in events if e.get("type") == "turn.failed"]
90
+ if failed:
91
+ error = str((failed[-1].get("error") or {}).get("message") or "turn failed")
92
+ elif not any(e.get("type") == "turn.completed" for e in events):
93
+ top_errors = [e.get("message") for e in events if e.get("type") == "error"]
94
+ error = str(top_errors[-1]) if top_errors else "codex exited without completing a turn"
95
+
96
+ # Non-fatal problems Codex reports as error items (e.g. unknown model metadata).
97
+ warnings = [str(i.get("message")) for i in items if i.get("type") == "error"]
98
+
99
+ triggered = None
100
+ if skill_name is not None:
101
+ triggered = any(is_skill_call(c, skill_name) for c in calls)
102
+
103
+ return AgentRun(
104
+ final_text=messages[-1] if messages else "",
105
+ transcript=events,
106
+ tool_calls=calls,
107
+ usage=parse_usage(events),
108
+ num_turns=sum(1 for e in events if e.get("type") == "turn.completed") or None,
109
+ triggered=triggered,
110
+ error=error,
111
+ warnings=warnings,
112
+ )
@@ -0,0 +1,34 @@
1
+ """Report renderers. Every format reads the same `Summary` (see summary.py)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from skillproof.report import html_report, json_report, markdown
8
+ from skillproof.report.summary import Summary, build_summary
9
+ from skillproof.results import SuiteResult
10
+
11
+ FORMATS = ("terminal", "md", "html", "json")
12
+
13
+
14
+ def render_text(fmt: str, summary: Summary, suite: SuiteResult) -> str:
15
+ """Render a file-style format (md, html, json) to a string."""
16
+ if fmt == "md":
17
+ return markdown.render(summary)
18
+ if fmt == "html":
19
+ return html_report.render(summary, suite)
20
+ if fmt == "json":
21
+ return json_report.render(summary)
22
+ raise ValueError(f"not a file format: {fmt}")
23
+
24
+
25
+ def write_reports(suite: SuiteResult) -> Summary:
26
+ """Save summary.json, report.md and report.html next to the transcripts."""
27
+ summary = build_summary(suite)
28
+ run_dir = Path(suite.run_dir)
29
+ for name, fmt in (("summary.json", "json"), ("report.md", "md"), ("report.html", "html")):
30
+ (run_dir / name).write_text(render_text(fmt, summary, suite), encoding="utf-8")
31
+ return summary
32
+
33
+
34
+ __all__ = ["FORMATS", "Summary", "build_summary", "render_text", "write_reports"]
@@ -0,0 +1,60 @@
1
+ """shields.io badges for a skill's README.
2
+
3
+ Static badge (markdown): [![skillproof](https://img.shields.io/badge/...)](link)
4
+ Endpoint JSON (--json): {"schemaVersion": 1, "label": ..., "message": ..., "color": ...}
5
+ Host the JSON (e.g. a gist, updated by CI) and point shields.io/endpoint at it
6
+ to get a badge that updates itself.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from urllib.parse import quote
13
+
14
+ from skillproof.report.summary import Summary
15
+
16
+ LABEL = "skillproof"
17
+ HOMEPAGE = "https://github.com/akhilgarg07/skillproof"
18
+ COLORS = {
19
+ "helps": "brightgreen",
20
+ "hurts": "red",
21
+ "no clear effect": "lightgrey",
22
+ "too few runs": "lightgrey",
23
+ "not measured": "lightgrey",
24
+ }
25
+
26
+
27
+ def message(s: Summary) -> str:
28
+ if s.lift is None:
29
+ text = "lift not measured"
30
+ else:
31
+ text = f"{s.lift * 100:+.0f}% lift"
32
+ if s.verdict == "no clear effect":
33
+ text += " (n.s.)" # not significant: the 95% interval includes zero
34
+ elif s.verdict == "too few runs":
35
+ text += " (too few runs)"
36
+ if s.trigger_runs:
37
+ text += f" · {s.triggers}/{s.trigger_runs} triggered"
38
+ if s.approximate:
39
+ text += " · approx"
40
+ return text
41
+
42
+
43
+ def _escape(text: str) -> str:
44
+ """shields.io path escaping: '-' -> '--', '_' -> '__', then URL-encode."""
45
+ return quote(text.replace("-", "--").replace("_", "__"), safe="")
46
+
47
+
48
+ def badge_url(s: Summary) -> str:
49
+ return (
50
+ f"https://img.shields.io/badge/{_escape(LABEL)}-{_escape(message(s))}-{COLORS[s.verdict]}"
51
+ )
52
+
53
+
54
+ def markdown(s: Summary, link: str = HOMEPAGE) -> str:
55
+ return f"[![{LABEL}: {message(s)}]({badge_url(s)})]({link})"
56
+
57
+
58
+ def endpoint_json(s: Summary) -> str:
59
+ data = {"schemaVersion": 1, "label": LABEL, "message": message(s), "color": COLORS[s.verdict]}
60
+ return json.dumps(data, ensure_ascii=False)
@@ -0,0 +1,75 @@
1
+ """Number formatting shared by all report renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from skillproof.report.summary import Summary
9
+
10
+ DASH = "–"
11
+
12
+
13
+ def pct(value: float | None) -> str:
14
+ return DASH if value is None else f"{value * 100:.0f}%"
15
+
16
+
17
+ def signed_pct(value: float | None) -> str:
18
+ """+23% / −12% (typographic minus) / – for unknown."""
19
+ if value is None:
20
+ return DASH
21
+ text = f"{value * 100:+.0f}%"
22
+ return "±0%" if text in ("+0%", "-0%") else text.replace("-", "−")
23
+
24
+
25
+ def interval(ci: tuple[float, float] | None) -> str:
26
+ if ci is None:
27
+ return DASH
28
+ return f"{signed_pct(ci[0])} to {signed_pct(ci[1])}"
29
+
30
+
31
+ def fraction(num: int, den: int) -> str:
32
+ return DASH if den == 0 else f"{num}/{den}"
33
+
34
+
35
+ def seconds(value: float | None) -> str:
36
+ if value is None:
37
+ return DASH
38
+ return f"{value:.1f}s" if value < 100 else f"{value:.0f}s"
39
+
40
+
41
+ def tokens(value: float | None) -> str:
42
+ if value is None:
43
+ return DASH
44
+ return f"{value / 1000:.1f}k" if value >= 1000 else f"{value:.0f}"
45
+
46
+
47
+ def usd(value: float | None) -> str:
48
+ if value is None:
49
+ return DASH
50
+ return f"${value:.3f}" if value < 1 else f"${value:.2f}"
51
+
52
+
53
+ def summary_line(s: Summary) -> str:
54
+ """skill lift: +23% (95% CI +5% to +41%) | triggers 9/10 | tokens −12% ..."""
55
+ parts = [f"skill lift: {signed_pct(s.lift)}"]
56
+ if s.lift_ci is not None:
57
+ parts[0] += f" (95% CI {interval(s.lift_ci)})"
58
+ parts.append(f"triggers {fraction(s.triggers, s.trigger_runs)}")
59
+ if s.false_trigger_runs:
60
+ parts.append(f"false triggers {fraction(s.false_triggers, s.false_trigger_runs)}")
61
+ parts.append(f"tokens {signed_pct(s.token_delta)}")
62
+ if s.cost_delta is not None:
63
+ parts.append(f"cost {signed_pct(s.cost_delta)}")
64
+ w, wo = s.with_skill.mean_duration_s, s.without_skill.mean_duration_s
65
+ parts.append(f"latency {signed_pct(s.latency_delta)} ({seconds(w)} vs {seconds(wo)})")
66
+ return " | ".join(parts)
67
+
68
+
69
+ VERDICT_TEXT = {
70
+ "helps": "the skill helps",
71
+ "hurts": "the skill hurts",
72
+ "no clear effect": "no clear effect",
73
+ "too few runs": "too few runs for a verdict",
74
+ "not measured": "lift not measured",
75
+ }