proofstep-trajectory 0.1.0__tar.gz

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.
@@ -0,0 +1,57 @@
1
+
2
+ __pycache__/
3
+ .coverage
4
+ .coverage.*
5
+ .docker-data/
6
+ .DS_Store
7
+ .e2e-api.log
8
+ .env
9
+ .env.*
10
+ .env.prod
11
+ # Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
12
+ # negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
13
+ # line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
14
+ # `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
15
+ # anyone who cloned the repository. It was caught by CI running the same step.
16
+ !.env.example
17
+ !.env.prod.example
18
+ .hypothesis/
19
+ .idea/
20
+ .mypy_cache/
21
+ .next/
22
+ .proofstep/
23
+ .pytest_cache/
24
+ .ruff_cache/
25
+ .turbo/
26
+ .venv/
27
+ .vscode/
28
+ *.egg-info/
29
+ *.key
30
+ *.pem
31
+ *.proofstep.local.yaml
32
+ *.py[cod]
33
+ *.swp
34
+ *.tsbuildinfo
35
+ # Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
36
+ # Docker volumes
37
+ # Editors / OS
38
+ # is a backup with no access control and no retention.
39
+ # Node
40
+ # Proofstep local state
41
+ # Python
42
+ # Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
43
+ # Secrets and local config — never commit these
44
+ # The e2e stack's server log, written next to the repo so a CI failure can print it.
45
+ # to cover all of them rather than the default filename only.
46
+ ~/.proofstep/
47
+ backups/
48
+ build/
49
+ coverage.xml
50
+ credentials.json
51
+ dist/
52
+ htmlcov/
53
+ node_modules/
54
+ out/
55
+ proofstep-*.json
56
+ secrets/
57
+ venv/
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.5
2
+ Name: proofstep-trajectory
3
+ Version: 0.1.0
4
+ Summary: Proofstep trajectory policy engine — policy-as-code for tool-using agents
5
+ Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
6
+ Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
7
+ Project-URL: Repository, https://github.com/IlaKhan17/proofstep
8
+ Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
9
+ License-Expression: Apache-2.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: proofstep-types
20
+ Requires-Dist: pydantic>=2.9
21
+ Requires-Dist: pyyaml>=6.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # proofstep-trajectory
25
+
26
+ **Trajectory policy engine** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
27
+ agents that knows the difference between a regression and a bad day.
28
+
29
+ Evaluates what an agent *did* — the ordered sequence of tool calls, their arguments, and the state
30
+ they left behind — against policies written as reviewable YAML.
31
+
32
+ ```yaml
33
+ rules:
34
+ - id: no-send-before-approval
35
+ kind: forbidden_before
36
+ severity: block
37
+ action: gmail.send
38
+ before: approval_received
39
+ ```
40
+
41
+ An agent can produce a flawless email and still have sent it before approval. No output evaluator
42
+ can detect that; this is what does.
43
+
44
+ ## Documentation
45
+
46
+ Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
47
+
48
+ Apache-2.0.
@@ -0,0 +1,25 @@
1
+ # proofstep-trajectory
2
+
3
+ **Trajectory policy engine** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
4
+ agents that knows the difference between a regression and a bad day.
5
+
6
+ Evaluates what an agent *did* — the ordered sequence of tool calls, their arguments, and the state
7
+ they left behind — against policies written as reviewable YAML.
8
+
9
+ ```yaml
10
+ rules:
11
+ - id: no-send-before-approval
12
+ kind: forbidden_before
13
+ severity: block
14
+ action: gmail.send
15
+ before: approval_received
16
+ ```
17
+
18
+ An agent can produce a flawless email and still have sent it before approval. No output evaluator
19
+ can detect that; this is what does.
20
+
21
+ ## Documentation
22
+
23
+ Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
24
+
25
+ Apache-2.0.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "proofstep-trajectory"
3
+ version = "0.1.0"
4
+ description = "Proofstep trajectory policy engine — policy-as-code for tool-using agents"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ classifiers = [
9
+ "Development Status :: 4 - Beta",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: Apache Software License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Software Development :: Testing",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ dependencies = [
20
+ "proofstep-types",
21
+ "pydantic>=2.9",
22
+ "pyyaml>=6.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/IlaKhan17/proofstep"
27
+ Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
28
+ Repository = "https://github.com/IlaKhan17/proofstep"
29
+ Issues = "https://github.com/IlaKhan17/proofstep/issues"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/proofstep_trajectory"]
37
+
38
+ [tool.uv.sources]
39
+ proofstep-types = { workspace = true }
@@ -0,0 +1,53 @@
1
+ """Proofstep trajectory policy engine (pure library — no I/O).
2
+
3
+ Policy-as-code for tool-using agents: `(policy, trace) -> list[PolicyFailure]`.
4
+
5
+ Output evaluation asks whether the answer was good. This asks whether the agent
6
+ behaved legitimately on the way there — an agent can produce a flawless email and
7
+ still have sent it before approval, which no output evaluator can detect.
8
+ """
9
+
10
+ from importlib import metadata as _metadata
11
+
12
+ from proofstep_trajectory.engine import evaluate_policy
13
+ from proofstep_trajectory.events import (
14
+ EventRef,
15
+ PolicyFailure,
16
+ PolicyResult,
17
+ TrajectoryEvent,
18
+ )
19
+ from proofstep_trajectory.normalize import Normalized, args_hash, normalize
20
+ from proofstep_trajectory.parser import (
21
+ LoadedPolicy,
22
+ PolicyError,
23
+ check_actions,
24
+ load_policy,
25
+ load_policy_file,
26
+ )
27
+ from proofstep_trajectory.predicates import PredicateError, compile_predicate
28
+ from proofstep_trajectory.schema import Include, Policy
29
+
30
+ # Read from the installed distribution rather than written here twice. A hand-maintained
31
+ # copy drifts the first time a release bumps one and not the other — which it already did,
32
+ # reporting 0.1.0.dev0 from a 0.1.0 wheel.
33
+ __version__ = _metadata.version("proofstep-trajectory")
34
+
35
+ __all__ = [
36
+ "EventRef",
37
+ "Include",
38
+ "LoadedPolicy",
39
+ "Normalized",
40
+ "Policy",
41
+ "PolicyError",
42
+ "PolicyFailure",
43
+ "PolicyResult",
44
+ "PredicateError",
45
+ "TrajectoryEvent",
46
+ "args_hash",
47
+ "check_actions",
48
+ "compile_predicate",
49
+ "evaluate_policy",
50
+ "load_policy",
51
+ "load_policy_file",
52
+ "normalize",
53
+ ]
@@ -0,0 +1,71 @@
1
+ """Top-level evaluation: (policy, trace) -> PolicyResult."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Any
7
+
8
+ from proofstep_trajectory.events import PolicyFailure, PolicyResult
9
+ from proofstep_trajectory.matchers import MATCHERS, REQUIRES_COMPLETE
10
+ from proofstep_trajectory.normalize import Normalized, normalize
11
+ from proofstep_trajectory.parser import LoadedPolicy, check_actions
12
+ from proofstep_trajectory.predicates import PredicateError, evaluate
13
+ from proofstep_types import Trace
14
+
15
+
16
+ def evaluate_policy(loaded: LoadedPolicy, trace: Trace) -> PolicyResult:
17
+ """Evaluate every rule in the policy against one trace."""
18
+ policy = loaded.policy
19
+ norm = normalize(trace, policy)
20
+
21
+ failures: list[PolicyFailure] = []
22
+ inconclusive: list[str] = []
23
+ warnings = [*norm.warnings, *check_actions(loaded, norm.actions)]
24
+
25
+ for rule in policy.rules:
26
+ # Absence of evidence is not evidence of absence: a rule that asserts
27
+ # something *did* happen cannot be judged on a trajectory that lost spans.
28
+ # Rules that observe a forbidden action still run, because seeing it is
29
+ # valid evidence no matter what is missing.
30
+ if norm.incomplete and rule.kind in REQUIRES_COMPLETE:
31
+ inconclusive.append(rule.id)
32
+ continue
33
+
34
+ if rule.when is not None and rule.kind != "conditional":
35
+ if not _guard(rule, norm, warnings):
36
+ continue
37
+ elif rule.kind == "conditional" and not _guard(rule, norm, warnings):
38
+ continue
39
+
40
+ for failure in MATCHERS[rule.kind](rule, norm):
41
+ failures.append(
42
+ failure if failure.policy_line else _with_line(failure, loaded.line_for(rule.id))
43
+ )
44
+
45
+ return PolicyResult(
46
+ policy_name=policy.name,
47
+ failures=failures,
48
+ events=norm.events,
49
+ incomplete=norm.incomplete,
50
+ inconclusive_rules=inconclusive,
51
+ warnings=warnings,
52
+ )
53
+
54
+
55
+ def _guard(rule: Any, norm: Normalized, warnings: list[str]) -> bool:
56
+ """Evaluate a rule's `when` predicate. A broken predicate must not pass silently."""
57
+ namespace = {
58
+ "metadata": norm.state,
59
+ "state": norm.state,
60
+ "actions": dict.fromkeys(norm.actions, True),
61
+ "counts": {action: norm.counts(action) for action in norm.actions},
62
+ }
63
+ try:
64
+ return evaluate(rule.when, namespace)
65
+ except PredicateError as exc:
66
+ warnings.append(f"rule {rule.id!r}: `when` could not be evaluated ({exc}); rule skipped")
67
+ return False
68
+
69
+
70
+ def _with_line(failure: PolicyFailure, line: int | None) -> PolicyFailure:
71
+ return replace(failure, policy_line=line)
@@ -0,0 +1,93 @@
1
+ """Adapter exposing a policy as an evaluator.
2
+
3
+ Deliberately placed here rather than in `proofstep-core`. The two packages are
4
+ siblings in the layering contract and neither may import the other; this works
5
+ because `Evaluator` is a *structural* protocol, so an object with the right shape
6
+ satisfies it without any import. The only shared dependency is `proofstep-types`.
7
+
8
+ That is the payoff of protocol-based extension points: the integration costs nothing
9
+ architecturally.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from proofstep_trajectory.engine import evaluate_policy
18
+ from proofstep_trajectory.parser import LoadedPolicy, load_policy_file
19
+ from proofstep_types import Score, Severity
20
+
21
+
22
+ class TrajectoryEvaluator:
23
+ """Scores an example by evaluating its captured trajectory against a policy.
24
+
25
+ Emits 1.0 when the policy holds and 0.0 when a blocking rule fails. An
26
+ incomplete trajectory produces an *errored* score rather than a zero: the run
27
+ failed to observe enough to judge, which is a different fact from observing a
28
+ violation, and gating on the difference is the whole point.
29
+ """
30
+
31
+ version = 1
32
+
33
+ def __init__(
34
+ self,
35
+ policy: LoadedPolicy | str | Path,
36
+ *,
37
+ name: str | None = None,
38
+ fail_on_warnings: bool = False,
39
+ ) -> None:
40
+ self.loaded = policy if isinstance(policy, LoadedPolicy) else load_policy_file(policy)
41
+ self.name = name or self.loaded.policy.name.replace("-", "_")
42
+ self.fail_on_warnings = fail_on_warnings
43
+ self.requires_trace = True
44
+
45
+ async def evaluate(self, ctx: Any) -> Score:
46
+ trace = getattr(ctx, "trace", None)
47
+ if trace is None:
48
+ return Score.failure(
49
+ self.name,
50
+ f"policy {self.loaded.policy.name!r} needs a captured trajectory but the "
51
+ "task produced no trace; is the task instrumented with the SDK?",
52
+ )
53
+
54
+ result = evaluate_policy(self.loaded, trace)
55
+
56
+ if result.inconclusive_rules:
57
+ return Score.failure(
58
+ self.name,
59
+ f"trajectory is incomplete, so {len(result.inconclusive_rules)} rule(s) "
60
+ f"could not be judged: {', '.join(result.inconclusive_rules)}",
61
+ )
62
+
63
+ failing = [
64
+ f
65
+ for f in result.failures
66
+ if f.severity is Severity.BLOCK or (self.fail_on_warnings and f.blocking is False)
67
+ ]
68
+ passed = not failing
69
+
70
+ return Score(
71
+ metric=self.name,
72
+ value=1.0 if passed else 0.0,
73
+ passed=passed,
74
+ reasoning=result.format(policy_path=self.loaded.path) if failing else None,
75
+ raw={
76
+ "policy": self.loaded.policy.name,
77
+ "policy_hash": self.loaded.content_hash,
78
+ "event_count": len(result.events),
79
+ "failures": [
80
+ {
81
+ "rule_id": f.rule_id,
82
+ "rule_kind": f.rule_kind,
83
+ "severity": f.severity.value,
84
+ "message": f.message,
85
+ "span_id": f.offending_span_id,
86
+ "event_index": f.offending_event_index,
87
+ "policy_line": f.policy_line,
88
+ }
89
+ for f in result.failures
90
+ ],
91
+ "warnings": result.warnings,
92
+ },
93
+ )
@@ -0,0 +1,150 @@
1
+ """The normalized trajectory event, and the result of evaluating a policy.
2
+
3
+ Rules never touch spans. They operate on this flat, ordered event list, so a
4
+ normalization fix corrects every rule at once instead of twelve times.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ from proofstep_types import Severity, Status
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class TrajectoryEvent:
18
+ index: int
19
+ action: str
20
+ span_id: str
21
+ parent_span_id: str | None
22
+ depth: int
23
+ started_at: datetime
24
+ ended_at: datetime | None
25
+ status: Status
26
+ args: dict[str, Any] = field(default_factory=dict)
27
+ args_hash: str = ""
28
+ attempt: int = 1
29
+ is_retry: bool = False
30
+ parallel_group: str | None = None
31
+ result_summary: Any = None
32
+
33
+ @property
34
+ def failed(self) -> bool:
35
+ return self.status in (Status.ERROR, Status.TIMEOUT)
36
+
37
+ def key(self, parts: list[str]) -> tuple[Any, ...]:
38
+ """Composite identity used by unique_action and no_loop."""
39
+ values: list[Any] = []
40
+ for part in parts:
41
+ if part == "action":
42
+ values.append(self.action)
43
+ elif part == "args_hash":
44
+ values.append(self.args_hash)
45
+ elif part.startswith("args."):
46
+ values.append(_dig(self.args, part[5:]))
47
+ else:
48
+ values.append(getattr(self, part, None))
49
+ return tuple(values)
50
+
51
+
52
+ def _dig(source: dict[str, Any], path: str) -> Any:
53
+ current: Any = source
54
+ for part in path.split("."):
55
+ if not isinstance(current, dict) or part not in current:
56
+ return None
57
+ current = current[part]
58
+ return current
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class EventRef:
63
+ """A pointer back to a span, so every finding is clickable."""
64
+
65
+ index: int
66
+ span_id: str
67
+ action: str
68
+ at: datetime
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class PolicyFailure:
73
+ rule_id: str
74
+ rule_kind: str
75
+ severity: Severity
76
+ message: str
77
+ offending_span_id: str | None = None
78
+ offending_event_index: int | None = None
79
+ offending_action: str = ""
80
+ expected: Any = None
81
+ actual: Any = None
82
+ evidence: list[EventRef] = field(default_factory=list)
83
+ policy_line: int | None = None
84
+
85
+ @property
86
+ def blocking(self) -> bool:
87
+ return self.severity is Severity.BLOCK
88
+
89
+ def format(self, *, policy_path: str | None = None) -> str:
90
+ """Human-readable rendering.
91
+
92
+ Message quality is a feature. Every failure states what happened, where,
93
+ what was expected, and how to go look — "policy violation" is banned.
94
+ """
95
+ mark = "✗" if self.blocking else "⚠"
96
+ lines = [f"{mark} {self.rule_id} [{self.severity.value}]", f" {self.message}"]
97
+ if self.offending_span_id:
98
+ label = f"{self.offending_action} " if self.offending_action else ""
99
+ where = f" offending : {label}span {self.offending_span_id}"
100
+ if self.offending_event_index is not None:
101
+ where += f" (event #{self.offending_event_index})"
102
+ lines.append(where)
103
+ if self.expected is not None:
104
+ lines.append(f" expected : {self.expected}")
105
+ if self.actual is not None:
106
+ lines.append(f" observed : {self.actual}")
107
+ if policy_path:
108
+ suffix = f":{self.policy_line}" if self.policy_line else ""
109
+ lines.append(f" policy : {policy_path}{suffix}")
110
+ return "\n".join(lines)
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class PolicyResult:
115
+ policy_name: str
116
+ failures: list[PolicyFailure] = field(default_factory=list)
117
+ events: list[TrajectoryEvent] = field(default_factory=list)
118
+ incomplete: bool = False
119
+ inconclusive_rules: list[str] = field(default_factory=list)
120
+ warnings: list[str] = field(default_factory=list)
121
+
122
+ @property
123
+ def passed(self) -> bool:
124
+ """Inconclusive is not passed.
125
+
126
+ A trajectory that lost spans cannot support an assertion about what did not
127
+ happen, and reporting that as compliance would be a false negative in the
128
+ direction that matters.
129
+ """
130
+ return not any(f.blocking for f in self.failures) and not self.inconclusive_rules
131
+
132
+ @property
133
+ def score(self) -> float:
134
+ return 1.0 if self.passed else 0.0
135
+
136
+ @property
137
+ def blocking_failures(self) -> list[PolicyFailure]:
138
+ return [f for f in self.failures if f.blocking]
139
+
140
+ def format(self, *, policy_path: str | None = None) -> str:
141
+ if not self.failures and not self.inconclusive_rules:
142
+ return f"✓ {self.policy_name}: {len(self.events)} events, no violations"
143
+ blocks = [f.format(policy_path=policy_path) for f in self.failures]
144
+ for rule_id in self.inconclusive_rules:
145
+ blocks.append(
146
+ f"? {rule_id} [inconclusive]\n"
147
+ " The trajectory is incomplete (spans were dropped or left open), so "
148
+ "this rule cannot assert that something did not happen."
149
+ )
150
+ return "\n\n".join(blocks)