oathgate 0.0.1__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.
- oathgate-0.0.1/.gitignore +10 -0
- oathgate-0.0.1/LICENSE +21 -0
- oathgate-0.0.1/PKG-INFO +85 -0
- oathgate-0.0.1/README.md +79 -0
- oathgate-0.0.1/examples/dataset.jsonl +3 -0
- oathgate-0.0.1/examples/spec.json +22 -0
- oathgate-0.0.1/pyproject.toml +14 -0
- oathgate-0.0.1/src/oathgate/__init__.py +3 -0
- oathgate-0.0.1/src/oathgate/cli.py +107 -0
oathgate-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Uladzimir Khadakouski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT Uladzimir KhadakouskiS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
oathgate-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oathgate
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Description-Content-Type: text/markdown
|
|
6
|
+
|
|
7
|
+
# oathgate
|
|
8
|
+
|
|
9
|
+
**Take the oath before you run the eval.**
|
|
10
|
+
|
|
11
|
+
`oathgate` blocks an evaluation run until two things are on the record:
|
|
12
|
+
|
|
13
|
+
1. **The measurement ruler is frozen** — metrics, thresholds, dataset composition and references are hashed.
|
|
14
|
+
2. **A prediction is written down** — what you expect to happen, before you know what happened.
|
|
15
|
+
|
|
16
|
+
Nothing here measures your model. It measures whether you were honest about how you measured.
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
Evals drift. Not because anyone lies, but because the ruler is soft: a threshold moves from 0.85 to 0.80, three hard cases quietly leave the set, a metric is swapped for a friendlier one — and the number goes up. Every step is defensible on its own. The result is a benchmark that only ever improves.
|
|
21
|
+
|
|
22
|
+
The fix is not more rigour in the moment. It is making the ruler expensive to change *after* you have seen the outcome.
|
|
23
|
+
|
|
24
|
+
## What gets hashed
|
|
25
|
+
|
|
26
|
+
Only the ruler:
|
|
27
|
+
|
|
28
|
+
| Hashed | Not hashed |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `metrics` | prompt |
|
|
31
|
+
| `thresholds` | model / checkpoint |
|
|
32
|
+
| `dataset` (composition) | agent scaffold |
|
|
33
|
+
| `references` (gold answers) | temperature, seeds, infra |
|
|
34
|
+
|
|
35
|
+
This split is the whole design. You are *supposed* to change the system under test — that is the experiment. The ruler is what has to hold still for the comparison to mean anything.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Freeze the ruler and commit to a prediction:
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ oathgate freeze examples/spec.json --predict "f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar"
|
|
43
|
+
frozen 4f2a91c0d3e8 -> oath.lock.json
|
|
44
|
+
oath f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then, as the first step of your run:
|
|
48
|
+
|
|
49
|
+
```console
|
|
50
|
+
$ oathgate check && python run_eval.py
|
|
51
|
+
ok 4f2a91c0d3e8 ruler unchanged
|
|
52
|
+
oath f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If someone nudged a threshold in between, the gate refuses and exits non-zero:
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ oathgate check
|
|
59
|
+
BLOCKED: the measurement ruler changed after the oath was taken.
|
|
60
|
+
frozen: 4f2a91c0d3e8 (2026-08-31T09:14:00+00:00)
|
|
61
|
+
current: b71e05ad9c2f
|
|
62
|
+
Re-freeze deliberately, or restore the spec. Do not do it silently.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Re-freezing is allowed. It just cannot happen by accident, and it leaves a timestamp.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```console
|
|
70
|
+
pip install oathgate
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Spec format
|
|
74
|
+
|
|
75
|
+
A JSON file with the four ruler keys. Anything else — including a `system` block describing what you are testing — is ignored by the hash and free to change.
|
|
76
|
+
|
|
77
|
+
See [`examples/spec.json`](examples/spec.json).
|
|
78
|
+
|
|
79
|
+
## Status
|
|
80
|
+
|
|
81
|
+
Early. The CLI is the whole surface right now; an MCP wrapper is planned so agents can be held to the same gate.
|
|
82
|
+
|
|
83
|
+
## Licence
|
|
84
|
+
|
|
85
|
+
MIT
|
oathgate-0.0.1/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# oathgate
|
|
2
|
+
|
|
3
|
+
**Take the oath before you run the eval.**
|
|
4
|
+
|
|
5
|
+
`oathgate` blocks an evaluation run until two things are on the record:
|
|
6
|
+
|
|
7
|
+
1. **The measurement ruler is frozen** — metrics, thresholds, dataset composition and references are hashed.
|
|
8
|
+
2. **A prediction is written down** — what you expect to happen, before you know what happened.
|
|
9
|
+
|
|
10
|
+
Nothing here measures your model. It measures whether you were honest about how you measured.
|
|
11
|
+
|
|
12
|
+
## The problem
|
|
13
|
+
|
|
14
|
+
Evals drift. Not because anyone lies, but because the ruler is soft: a threshold moves from 0.85 to 0.80, three hard cases quietly leave the set, a metric is swapped for a friendlier one — and the number goes up. Every step is defensible on its own. The result is a benchmark that only ever improves.
|
|
15
|
+
|
|
16
|
+
The fix is not more rigour in the moment. It is making the ruler expensive to change *after* you have seen the outcome.
|
|
17
|
+
|
|
18
|
+
## What gets hashed
|
|
19
|
+
|
|
20
|
+
Only the ruler:
|
|
21
|
+
|
|
22
|
+
| Hashed | Not hashed |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `metrics` | prompt |
|
|
25
|
+
| `thresholds` | model / checkpoint |
|
|
26
|
+
| `dataset` (composition) | agent scaffold |
|
|
27
|
+
| `references` (gold answers) | temperature, seeds, infra |
|
|
28
|
+
|
|
29
|
+
This split is the whole design. You are *supposed* to change the system under test — that is the experiment. The ruler is what has to hold still for the comparison to mean anything.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Freeze the ruler and commit to a prediction:
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
$ oathgate freeze examples/spec.json --predict "f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar"
|
|
37
|
+
frozen 4f2a91c0d3e8 -> oath.lock.json
|
|
38
|
+
oath f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then, as the first step of your run:
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ oathgate check && python run_eval.py
|
|
45
|
+
ok 4f2a91c0d3e8 ruler unchanged
|
|
46
|
+
oath f1 lands between 0.88 and 0.92; exact_match misses the 0.80 bar
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If someone nudged a threshold in between, the gate refuses and exits non-zero:
|
|
50
|
+
|
|
51
|
+
```console
|
|
52
|
+
$ oathgate check
|
|
53
|
+
BLOCKED: the measurement ruler changed after the oath was taken.
|
|
54
|
+
frozen: 4f2a91c0d3e8 (2026-08-31T09:14:00+00:00)
|
|
55
|
+
current: b71e05ad9c2f
|
|
56
|
+
Re-freeze deliberately, or restore the spec. Do not do it silently.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Re-freezing is allowed. It just cannot happen by accident, and it leaves a timestamp.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```console
|
|
64
|
+
pip install oathgate
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Spec format
|
|
68
|
+
|
|
69
|
+
A JSON file with the four ruler keys. Anything else — including a `system` block describing what you are testing — is ignored by the hash and free to change.
|
|
70
|
+
|
|
71
|
+
See [`examples/spec.json`](examples/spec.json).
|
|
72
|
+
|
|
73
|
+
## Status
|
|
74
|
+
|
|
75
|
+
Early. The CLI is the whole surface right now; an MCP wrapper is planned so agents can be held to the same gate.
|
|
76
|
+
|
|
77
|
+
## Licence
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"id": "1", "input": "Ship it by Friday, and drop the dark mode toggle.", "expected": ["deadline:friday", "remove:dark-mode-toggle"]}
|
|
2
|
+
{"id": "2", "input": "Can we make the export button blue?", "expected": ["change:export-button-colour"]}
|
|
3
|
+
{"id": "3", "input": "Nothing to change on my side.", "expected": []}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "synthetic-extraction-eval",
|
|
3
|
+
"metrics": [
|
|
4
|
+
"exact_match",
|
|
5
|
+
"f1"
|
|
6
|
+
],
|
|
7
|
+
"thresholds": {
|
|
8
|
+
"exact_match": 0.8,
|
|
9
|
+
"f1": 0.9
|
|
10
|
+
},
|
|
11
|
+
"dataset": {
|
|
12
|
+
"path": "examples/dataset.jsonl",
|
|
13
|
+
"n": 3
|
|
14
|
+
},
|
|
15
|
+
"references": "examples/dataset.jsonl",
|
|
16
|
+
"system": {
|
|
17
|
+
"_comment": "Everything under `system` is intentionally NOT hashed.",
|
|
18
|
+
"model": "another-model:latest",
|
|
19
|
+
"prompt": "prompts/extract.md",
|
|
20
|
+
"temperature": 0.0
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "oathgate"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
|
|
6
|
+
[project.scripts]
|
|
7
|
+
oathgate = "oathgate.cli:main"
|
|
8
|
+
|
|
9
|
+
[tool.hatch.build.targets.wheel]
|
|
10
|
+
packages = ["src/oathgate"]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""oathgate — freeze the measurement ruler before you run the eval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Only these keys describe the *ruler*: how a result is measured.
|
|
13
|
+
# The system under test (prompt, model, agent, temperature) is deliberately
|
|
14
|
+
# excluded — you are allowed to change it, that is the whole point.
|
|
15
|
+
RULER_KEYS = ("metrics", "thresholds", "dataset", "references")
|
|
16
|
+
|
|
17
|
+
LOCK_NAME = "oath.lock.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _canonical(spec: dict) -> str:
|
|
21
|
+
ruler = {k: spec[k] for k in RULER_KEYS if k in spec}
|
|
22
|
+
return json.dumps(ruler, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _digest(spec: dict) -> str:
|
|
26
|
+
return hashlib.sha256(_canonical(spec).encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load(path: Path) -> dict:
|
|
30
|
+
if not path.exists():
|
|
31
|
+
sys.exit(f"oathgate: spec not found: {path}")
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except json.JSONDecodeError as exc:
|
|
35
|
+
sys.exit(f"oathgate: cannot parse {path}: {exc}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_freeze(args: argparse.Namespace) -> int:
|
|
39
|
+
spec_path = Path(args.spec)
|
|
40
|
+
spec = _load(spec_path)
|
|
41
|
+
|
|
42
|
+
missing = [k for k in RULER_KEYS if k not in spec]
|
|
43
|
+
if missing:
|
|
44
|
+
sys.exit(f"oathgate: spec is missing required keys: {', '.join(missing)}")
|
|
45
|
+
|
|
46
|
+
if not args.predict:
|
|
47
|
+
sys.exit("oathgate: refusing to freeze without a prediction (--predict)")
|
|
48
|
+
|
|
49
|
+
lock = {
|
|
50
|
+
"version": 1,
|
|
51
|
+
"spec": str(spec_path),
|
|
52
|
+
"ruler_sha256": _digest(spec),
|
|
53
|
+
"prediction": args.predict,
|
|
54
|
+
"frozen_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
55
|
+
}
|
|
56
|
+
lock_path = Path(args.lock)
|
|
57
|
+
lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8")
|
|
58
|
+
print(f"frozen {lock['ruler_sha256'][:12]} -> {lock_path}")
|
|
59
|
+
print(f"oath {args.predict}")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
64
|
+
lock_path = Path(args.lock)
|
|
65
|
+
if not lock_path.exists():
|
|
66
|
+
sys.exit(f"oathgate: no lock file ({lock_path}). Run `oathgate freeze` first.")
|
|
67
|
+
|
|
68
|
+
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
|
69
|
+
spec = _load(Path(args.spec or lock["spec"]))
|
|
70
|
+
current = _digest(spec)
|
|
71
|
+
|
|
72
|
+
if current != lock["ruler_sha256"]:
|
|
73
|
+
print("BLOCKED: the measurement ruler changed after the oath was taken.")
|
|
74
|
+
print(f" frozen: {lock['ruler_sha256'][:12]} ({lock['frozen_at']})")
|
|
75
|
+
print(f" current: {current[:12]}")
|
|
76
|
+
print(" Re-freeze deliberately, or restore the spec. Do not do it silently.")
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
print(f"ok {current[:12]} ruler unchanged")
|
|
80
|
+
print(f"oath {lock['prediction']}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(
|
|
86
|
+
prog="oathgate",
|
|
87
|
+
description="Freeze the eval scoring spec and your prediction before the run.",
|
|
88
|
+
)
|
|
89
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
90
|
+
|
|
91
|
+
f = sub.add_parser("freeze", help="freeze the ruler and record a prediction")
|
|
92
|
+
f.add_argument("spec", help="path to the eval spec (JSON)")
|
|
93
|
+
f.add_argument("--predict", required=True, help="what you expect to happen")
|
|
94
|
+
f.add_argument("--lock", default=LOCK_NAME, help=f"lock file (default: {LOCK_NAME})")
|
|
95
|
+
f.set_defaults(func=cmd_freeze)
|
|
96
|
+
|
|
97
|
+
c = sub.add_parser("check", help="verify the ruler is unchanged before a run")
|
|
98
|
+
c.add_argument("spec", nargs="?", help="path to the eval spec (default: from lock)")
|
|
99
|
+
c.add_argument("--lock", default=LOCK_NAME, help=f"lock file (default: {LOCK_NAME})")
|
|
100
|
+
c.set_defaults(func=cmd_check)
|
|
101
|
+
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
return args.func(args)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|