opguard-harness 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.
@@ -0,0 +1,3 @@
1
+ """OpGuard orchestration, independent of the tracing runtime and model provider."""
2
+
3
+ __version__ = '0.1.0'
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
opguard_harness/cli.py ADDED
@@ -0,0 +1,35 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+
5
+ from .tools import TOOLS, dispatch
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(description='Provider-neutral OpGuard agent tools')
10
+ parser.add_argument('--root', default='./experiments', help='Persistent experiment store; shared with workers')
11
+ sub = parser.add_subparsers(dest='command', required=True)
12
+ sub.add_parser('tools', help='Print function schemas')
13
+ call = sub.add_parser('call', help='Execute one tool and print one JSON response')
14
+ call.add_argument('name', choices=[t['name'] for t in TOOLS])
15
+ call.add_argument('--arguments', help='JSON file; defaults to stdin')
16
+ args = parser.parse_args()
17
+ if args.command == 'tools':
18
+ print(json.dumps({'protocol_version': 1, 'tools': TOOLS}, indent=2))
19
+ return 0
20
+ try:
21
+ if args.arguments:
22
+ with open(args.arguments) as stream:
23
+ arguments = json.load(stream)
24
+ else:
25
+ arguments = json.load(sys.stdin)
26
+ result = dispatch(args.root, args.name, arguments)
27
+ print(json.dumps({'ok': True, 'result': result}, ensure_ascii=False))
28
+ return 0
29
+ except Exception as exc:
30
+ print(json.dumps({'ok': False, 'error': {'type': type(exc).__name__, 'message': str(exc)}}))
31
+ return 1
32
+
33
+
34
+ if __name__ == '__main__':
35
+ sys.exit(main())
@@ -0,0 +1,181 @@
1
+ """A fixed, inspectable workflow. The agent chooses when to advance or refine."""
2
+ from datetime import datetime, timezone
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import uuid
8
+
9
+ from .spec import validate
10
+ from .store import Store, atomic_json
11
+
12
+ STAGES = [(side, phase) for side in ('baseline', 'candidate')
13
+ for phase in ('discover', 'prepare', 'trace')] + [('comparison', 'compare')]
14
+
15
+
16
+ def now():
17
+ return datetime.now(timezone.utc).isoformat()
18
+
19
+
20
+ class Harness:
21
+ def __init__(self, root):
22
+ self.store = Store(root)
23
+
24
+ def create(self, spec):
25
+ spec = validate(spec)
26
+ experiment_id = uuid.uuid4().hex
27
+ self.store.path(experiment_id).mkdir()
28
+ record = {'version': 1, 'id': experiment_id, 'created_at': now(), 'status': 'pending',
29
+ 'spec': spec, 'stages': [{'side': side, 'action': action, 'status': 'pending',
30
+ 'attempts': []} for side, action in STAGES]}
31
+ self.store.save(record)
32
+ return self.status(experiment_id)
33
+
34
+ def status(self, experiment_id):
35
+ record = self.store.load(experiment_id)
36
+ stage = next((s for s in record['stages'] if s['status'] != 'succeeded'), None)
37
+ return {'id': experiment_id, 'status': record['status'], 'next':
38
+ {'side': stage['side'], 'action': stage['action']} if stage else None,
39
+ 'stages': record['stages']}
40
+
41
+ def results(self, experiment_id):
42
+ record = self.store.load(experiment_id)
43
+ last = record['stages'][-1]
44
+ return {'id': experiment_id, 'status': record['status'],
45
+ 'comparison': last.get('result'), 'verdict': 'not_evaluated',
46
+ 'complete': record['status'] == 'succeeded'}
47
+
48
+ def refine(self, experiment_id, spec):
49
+ # Explicit full spec, fresh experiment; old evidence stays immutable.
50
+ self.store.load(experiment_id)
51
+ child = self.create(spec)
52
+ with self.store.locked(child['id']) as record:
53
+ record['parent'] = experiment_id
54
+ self.store.save(record)
55
+ return child
56
+
57
+ def advance(self, experiment_id):
58
+ with self.store.locked(experiment_id) as record:
59
+ stage = next((s for s in record['stages'] if s['status'] != 'succeeded'), None)
60
+ if stage is None:
61
+ return self.status(experiment_id)
62
+ if stage['status'] == 'running':
63
+ raise RuntimeError('Previous controller interrupted. Inspect the recorded process/logs and use recover; do not launch duplicate workers')
64
+ if len(stage['attempts']) >= record['spec']['max_stage_attempts']:
65
+ raise RuntimeError('Stage attempt budget exhausted; refine into a new experiment')
66
+ number = len(stage['attempts']) + 1
67
+ attempt_dir = self.store.path(experiment_id) / f'{stage["side"]}-{stage["action"]}-{number}'
68
+ attempt_dir.mkdir()
69
+ request = self._request(record, stage, attempt_dir)
70
+ atomic_json(attempt_dir / 'request.json', request)
71
+ attempt = {'started_at': now(), 'directory': str(attempt_dir), 'status': 'running'}
72
+ stage['attempts'].append(attempt)
73
+ stage['status'] = record['status'] = 'running'
74
+ self.store.save(record)
75
+ env = {k: v for k, v in os.environ.items() if not k.startswith('OPGUARD_')}
76
+ env['OPGUARD_AUTOSTART'] = '0'
77
+ command = [*record['spec']['backend'], stage['action'], '--request', str(attempt_dir / 'request.json')]
78
+ try:
79
+ with (attempt_dir / 'response.json').open('w') as out, (attempt_dir / 'backend.log').open('w') as err:
80
+ # Inherit signals: Ctrl-C reaches the backend, which unwinds its
81
+ # source transaction. Never automatically retry interrupted jobs.
82
+ process = subprocess.Popen(command, stdout=out, stderr=err, env=env)
83
+ attempt['pid'] = process.pid
84
+ self.store.save(record)
85
+ code = process.wait()
86
+ response = json.loads((attempt_dir / 'response.json').read_text())
87
+ if code or response.get('protocol_version') != 1 or response.get('ok') is not True:
88
+ raise RuntimeError(f'Backend failed: {response.get("error", response)}')
89
+ result = response['result']
90
+ self._validate_result(stage['action'], request, result)
91
+ stage['result'] = result
92
+ stage['status'] = attempt['status'] = 'succeeded'
93
+ record['status'] = 'succeeded' if all(s['status'] == 'succeeded' for s in record['stages']) else 'pending'
94
+ except Exception as exc:
95
+ stage['status'] = attempt['status'] = 'failed'
96
+ record['status'] = 'failed'
97
+ attempt['error'] = str(exc)
98
+ finally:
99
+ attempt['finished_at'] = now()
100
+ self.store.save(record)
101
+ return self.status(experiment_id)
102
+
103
+ def recover(self, experiment_id):
104
+ # A controller crash is ambiguous. Only acknowledge after backend exit and
105
+ # completed source restoration; no stale PID guessing or automatic overwrite.
106
+ with self.store.locked(experiment_id) as record:
107
+ stage = next((s for s in record['stages'] if s['status'] == 'running'), None)
108
+ if stage is None:
109
+ raise ValueError('No interrupted running stage')
110
+ attempt = stage['attempts'][-1]
111
+ pid = attempt.get('pid')
112
+ if pid:
113
+ try:
114
+ os.kill(pid, 0)
115
+ except ProcessLookupError:
116
+ pass
117
+ else:
118
+ raise RuntimeError('Recorded backend PID still exists; inspect it before recovery')
119
+ path = Path(attempt['directory'])
120
+ if stage['action'] == 'trace':
121
+ state = path / 'output-instrument'
122
+ if state.exists() and not (state / 'restored.json').exists():
123
+ raise RuntimeError(f'Source restoration not confirmed. Inspect originals.json in {state}; recover sources before retrying')
124
+ # Discovery could leave detached remote workers. A successful backend
125
+ # response proves its blocking launcher returned and receipts validated.
126
+ response_path = path / 'response.json'
127
+ try:
128
+ response = json.loads(response_path.read_text())
129
+ except (OSError, ValueError):
130
+ raise RuntimeError('No terminal backend response; verify/cancel launcher workers before creating a new experiment')
131
+ if response.get('protocol_version') != 1 or not isinstance(response.get('ok'), bool):
132
+ raise RuntimeError('Invalid terminal backend response')
133
+ if response['ok']:
134
+ request = json.loads((path / 'request.json').read_text())
135
+ self._validate_result(stage['action'], request, response['result'])
136
+ stage['result'] = response['result']
137
+ stage['status'] = attempt['status'] = 'succeeded'
138
+ record['status'] = 'succeeded' if all(s['status'] == 'succeeded' for s in record['stages']) else 'pending'
139
+ else:
140
+ stage['status'] = attempt['status'] = 'failed'
141
+ attempt['error'] = str(response.get('error'))
142
+ record['status'] = 'failed'
143
+ attempt['recovered_at'] = now()
144
+ self.store.save(record)
145
+ return self.status(experiment_id)
146
+
147
+ @staticmethod
148
+ def _result(record, side, action):
149
+ return next(s['result'] for s in record['stages'] if s['side'] == side and s['action'] == action)
150
+
151
+ def _request(self, record, stage, directory):
152
+ spec, side, action = record['spec'], stage['side'], stage['action']
153
+ request = {'protocol_version': 1, 'output': str(directory / 'output'),
154
+ 'local_root': spec['local_root'], 'timeout_seconds': spec['timeout_seconds']}
155
+ if side in ('baseline', 'candidate'):
156
+ request['workload'] = spec[side]
157
+ if action == 'prepare':
158
+ request['discovery'] = self._result(record, side, 'discover')
159
+ if action == 'trace':
160
+ request['plan'] = self._result(record, side, 'prepare')
161
+ if action == 'compare':
162
+ request.update(baseline=self._result(record, 'baseline', 'trace'),
163
+ candidate=self._result(record, 'candidate', 'trace'),
164
+ ranks=spec['baseline']['ranks'])
165
+ return request
166
+
167
+ @staticmethod
168
+ def _validate_result(action, request, result):
169
+ if not isinstance(result, dict):
170
+ raise ValueError('Backend result must be an object')
171
+ if action in ('discover', 'trace'):
172
+ if result.get('output') != request['output'] or result.get('ranks') != request['workload']['ranks'] or result.get('phase') != action:
173
+ raise ValueError('Capture result does not match the requested phase/output/ranks')
174
+ elif action == 'prepare':
175
+ if not result.get('sources') or not Path(result['config']).is_file() or not result.get('config_sha256'):
176
+ raise ValueError('Invalid instrumentation plan')
177
+ else:
178
+ if result.get('verdict') != 'not_evaluated' or result.get('ranks') != request['ranks'] or not result.get('reports'):
179
+ raise ValueError('Invalid comparison result')
180
+ if len(result['reports']) != len(request['ranks']) or any(not Path(p).is_file() for p in result['reports']):
181
+ raise ValueError('Missing comparison reports')
@@ -0,0 +1,63 @@
1
+ """Interpret recorded regional fingerprints, without inferring value equality."""
2
+
3
+
4
+ def _layout(tensor):
5
+ layout = tensor.get('xor_sig_layout')
6
+ values = tensor.get('xor_signature')
7
+ if tensor.get('xor_sig_mode') != 'bytes' or not isinstance(layout, dict):
8
+ return None
9
+ count, size = layout.get('regions'), layout.get('logical_nbytes')
10
+ if (layout.get('scheme') != 'flat-u32-le-v1' or type(count) is not int
11
+ or not 1 <= count <= 64 or type(size) is not int or size < 0):
12
+ return None
13
+ if count == 1 and type(values) in (int, float):
14
+ values = [values]
15
+ if not isinstance(values, list) or len(values) != count or any(
16
+ type(x) not in (int, float) or not 0 <= x <= 0xffffffff or int(x) != x for x in values):
17
+ return None
18
+ base, rem = divmod((size + 3) // 4, count)
19
+ ranges, start = [], 0
20
+ for i in range(count):
21
+ end = start + base + (i < rem)
22
+ ranges.append([min(start * 4, size), min(end * 4, size)])
23
+ start = end
24
+ if layout.get('byte_ranges') != ranges:
25
+ return None
26
+ return layout, list(map(int, values))
27
+
28
+
29
+ def compare_regions(left, right):
30
+ a, b = _layout(left), _layout(right)
31
+ if a is None or b is None:
32
+ return {'status': 'unavailable', 'reason': 'missing_or_unsupported_content_fingerprint_layout'}
33
+ if a[0] != b[0] or left.get('dtype') != right.get('dtype'):
34
+ return {'status': 'incompatible', 'reason': 'different_fingerprint_layout_or_dtype'}
35
+ regions = [{'region': i, 'byte_range': bounds, 'left': av, 'right': bv,
36
+ 'fingerprint_xor': av ^ bv, 'fingerprints_agree': av == bv}
37
+ for i, (bounds, av, bv) in enumerate(zip(a[0]['byte_ranges'], a[1], b[1]))]
38
+ return {'status': 'available', 'scheme': a[0]['scheme'], 'regions': regions,
39
+ 'differing_regions': [r['region'] for r in regions if not r['fingerprints_agree']],
40
+ 'tensor_equality': 'not_established', 'different_element_count': 'not_available',
41
+ 'padding_assessment': 'not_determined'}
42
+
43
+
44
+ def regional_comparisons(left, right):
45
+ """Same-slot proposals, or the explicit selected slots of a reviewed pair."""
46
+ a, b = left.get('io') or {}, right.get('io') or {}
47
+ claim = left.get('correspondence') or {}
48
+ selected = (claim.get('basis') == 'explicit_alignment_map'
49
+ and claim.get('peer_record_id') == right.get('record_id')
50
+ and claim.get('tensor_slot') is not None)
51
+ if selected:
52
+ slots = [(claim['tensor_slot'], claim['peer_tensor_slot'])]
53
+ else:
54
+ slots = [([side, i], [side, i]) for side in ('inputs', 'outputs')
55
+ for i in range(min(len(a.get(side, [])), len(b.get(side, []))))]
56
+ tensors = []
57
+ for x, y in slots[:64]:
58
+ av, bv = a[x[0]][x[1]], b[y[0]][y[1]]
59
+ tensors.append({'left_tensor': x, 'right_tensor': y, **compare_regions(av, bv)})
60
+ return {'selection_basis': 'reviewed_tensor_slots' if selected else 'same_io_slot_candidates',
61
+ 'tensors': tensors, 'truncated': len(slots) > 64,
62
+ 'note': 'Region differences localize recorded byte changes, not element counts or padding noise. '
63
+ 'Matching regions may still contain XOR collisions. Validate semantic correspondence first.'}
@@ -0,0 +1,91 @@
1
+ """Copy an explicit input allowlist without repository history or answer files.
2
+
3
+ This is an artifact boundary, not a filesystem sandbox or a semantic leak detector.
4
+ The caller must review the selected source and provide a separate solver workspace.
5
+ """
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path, PurePosixPath
9
+ import shutil
10
+ import sys
11
+
12
+
13
+ EXCLUDED_PARTS = {".git", ".hg", ".svn", "__pycache__", "answers", "evidence"}
14
+
15
+
16
+ def disallowed_symlink(path):
17
+ if not path.is_symlink():
18
+ return False
19
+ # Only exempt macOS root aliases with their exact system targets.
20
+ # User-created directory and file links must still be rejected.
21
+ return not (sys.platform == "darwin" and path in {Path("/var"), Path("/tmp")}
22
+ and path.resolve() == Path("/private") / path.name)
23
+
24
+
25
+ def relative_path(value):
26
+ path = PurePosixPath(value)
27
+ if (not value or path.is_absolute() or "\\" in value
28
+ or any(part in {".", ".."} | EXCLUDED_PARTS for part in value.split("/"))
29
+ or str(path) != value or path.suffix in {".pyc", ".pyo"}):
30
+ raise ValueError(f"Not an input path: {value}")
31
+ return Path(*path.parts)
32
+
33
+
34
+ def sha256(path):
35
+ digest = hashlib.sha256()
36
+ with path.open("rb") as stream:
37
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
38
+ digest.update(chunk)
39
+ return digest.hexdigest()
40
+
41
+
42
+ def export_inputs(destination, files):
43
+ """Export {relative destination: explicit source file}; never walk a checkout.
44
+
45
+ The public manifest contains only destination paths and content hashes. Source
46
+ paths and case-to-answer mappings belong in the caller's private provenance.
47
+ """
48
+ destination = Path(destination)
49
+ prepared = []
50
+ for name, source in files.items():
51
+ target = relative_path(name)
52
+ if name == "manifest.json":
53
+ raise ValueError("manifest.json is reserved for the input inventory")
54
+ source = Path(source).absolute()
55
+ if any(disallowed_symlink(p) for p in (source, *source.parents)) or not source.is_file():
56
+ raise ValueError(f"Input must be a regular file without symlinks: {source}")
57
+ if any(part in EXCLUDED_PARTS for part in source.parts):
58
+ raise ValueError(f"Excluded source path: {source}")
59
+ prepared.append((target, source))
60
+ # Refuse merges into old bundles: stale captures may contain embedded answers.
61
+ destination.mkdir(parents=True, exist_ok=False)
62
+ hashes = {}
63
+ for target, source in prepared:
64
+ output = destination / target
65
+ output.parent.mkdir(parents=True, exist_ok=True)
66
+ shutil.copyfile(source, output)
67
+ hashes[target.as_posix()] = sha256(output)
68
+ manifest = {"version": 1, "files": hashes}
69
+ (destination / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
70
+ return manifest
71
+
72
+
73
+ def verify_inputs(directory):
74
+ """Reject missing/changed/unlisted files and symlinks before solver delivery."""
75
+ directory = Path(directory)
76
+ manifest = json.loads((directory / "manifest.json").read_text())
77
+ if manifest.get("version") != 1:
78
+ raise ValueError("Unsupported input manifest version")
79
+ expected = set(manifest["files"]) | {"manifest.json"}
80
+ actual = set()
81
+ for path in directory.rglob("*"):
82
+ if path.is_symlink():
83
+ raise ValueError(f"Symlink in input bundle: {path}")
84
+ if path.is_file():
85
+ actual.add(path.relative_to(directory).as_posix())
86
+ if actual != expected:
87
+ raise ValueError(f"Input inventory differs: {sorted(actual ^ expected)}")
88
+ for name, digest in manifest["files"].items():
89
+ if sha256(directory / relative_path(name)) != digest:
90
+ raise ValueError(f"Changed input: {name}")
91
+ return manifest
@@ -0,0 +1,209 @@
1
+ """Artifact-scoped agent reviews and a resumable difference queue.
2
+
3
+ Reviews are assertions with provenance, never automatic root-cause verdicts.
4
+ The append-only review history is also an OpGuard alignment-map v1 document.
5
+ """
6
+ import collections
7
+ from datetime import datetime, timezone
8
+ import fcntl
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import subprocess
14
+ import uuid
15
+
16
+ from .spec import strings
17
+ from .store import atomic_json
18
+ from .trace_reader import Trace
19
+
20
+ DECISIONS = {"comparable", "not_comparable", "undetermined"}
21
+ BASES = {"observed", "source_inference", "intervention_tested", "externally_reported"}
22
+ MAX_REVIEW_BYTES = 8 * 1024 * 1024
23
+
24
+
25
+ def _key(left, right):
26
+ return tuple(sorted((left, right)))
27
+
28
+
29
+ def _slot(trace, index, slot):
30
+ if (not isinstance(slot, list) or len(slot) != 2 or slot[0] not in ("inputs", "outputs")
31
+ or type(slot[1]) is not int or slot[1] < 0):
32
+ raise ValueError('Tensor slot must be [inputs|outputs, nonnegative index] in exported io')
33
+ values = (trace.events[index]['args'].get('io') or {}).get(slot[0], [])
34
+ if slot[1] >= len(values) or not isinstance(values[slot[1]], dict):
35
+ raise ValueError('Tensor slot is absent from exported io')
36
+
37
+
38
+ def _validate(trace, review):
39
+ required = {'left_ref', 'right_ref', 'left_tensor', 'right_tensor', 'decision', 'reason', 'evidence'}
40
+ if (not isinstance(review, dict) or not required <= review.keys()
41
+ or review.keys() - required - {'id', 'created_at'}):
42
+ raise ValueError('Invalid review fields')
43
+ a, b = trace.index(review['left_ref']), trace.index(review['right_ref'])
44
+ left, right = trace.events[a]['args'], trace.events[b]['args']
45
+ if not left.get('run') or not right.get('run') or left['run'] == right['run']:
46
+ raise ValueError('A review must relate different runs')
47
+ if review['decision'] not in DECISIONS or not isinstance(review['reason'], str) or not review['reason'].strip():
48
+ raise ValueError('A review requires a decision and a reason')
49
+ evidence = review['evidence']
50
+ if not isinstance(evidence, list) or not evidence:
51
+ raise ValueError('A review requires evidence references')
52
+ for item in evidence:
53
+ if (not isinstance(item, dict) or set(item) != {'basis', 'reference'}
54
+ or item['basis'] not in BASES or not isinstance(item['reference'], str)
55
+ or not item['reference'].strip()):
56
+ raise ValueError('Invalid evidence basis/reference')
57
+ lp, rp = review['left_tensor'], review['right_tensor']
58
+ if (lp is None) != (rp is None):
59
+ raise ValueError('Select both tensor slots or neither')
60
+ if lp is not None:
61
+ if review['decision'] != 'comparable':
62
+ raise ValueError('Negative/undetermined reviews address the whole proposed relation; use null slots')
63
+ _slot(trace, a, lp)
64
+ _slot(trace, b, rp)
65
+ elif review['decision'] == 'comparable':
66
+ aligned = trace.aligned(a)
67
+ if not aligned['unique'] or aligned['peers'][0]['event_ref'] != trace.ref(b):
68
+ raise ValueError('New correspondences require explicit tensor slots')
69
+
70
+
71
+ def _latest(trace, history):
72
+ latest = {}
73
+ for review in history:
74
+ _validate(trace, review)
75
+ latest[_key(review['left_ref'], review['right_ref'])] = review
76
+ used = set()
77
+ for review in latest.values():
78
+ if review['decision'] == 'comparable':
79
+ refs = {review['left_ref'], review['right_ref']}
80
+ if used & refs:
81
+ raise ValueError('Conflicting comparable reviews: one relation per operation per view')
82
+ used.update(refs)
83
+ return latest
84
+
85
+
86
+ def _path(root, trace):
87
+ return Path(root).expanduser().resolve() / 'reviews' / (trace.sha + '.json')
88
+
89
+
90
+ def _load(root, trace):
91
+ path = _path(root, trace)
92
+ if not path.exists():
93
+ return {'version': 1, 'artifact_sha256': trace.sha, 'reviews': []}
94
+ with path.open('rb') as stream:
95
+ raw = stream.read(MAX_REVIEW_BYTES + 1)
96
+ if len(raw) > MAX_REVIEW_BYTES:
97
+ raise ValueError('Review history exceeds the 8 MiB limit')
98
+ record = json.loads(raw)
99
+ if (record.get('version') != 1 or record.get('artifact_sha256') != trace.sha
100
+ or not isinstance(record.get('reviews'), list)):
101
+ raise ValueError('Review history belongs to another artifact or version')
102
+ _latest(trace, record['reviews'])
103
+ return record
104
+
105
+
106
+ def review_pair(root, artifact, review):
107
+ trace = Trace(artifact)
108
+ _validate(trace, review)
109
+ path = _path(root, trace)
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ with path.with_suffix('.lock').open('a') as lock:
112
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
113
+ record = _load(root, trace)
114
+ entry = {**review, 'id': uuid.uuid4().hex, 'created_at': datetime.now(timezone.utc).isoformat()}
115
+ record['reviews'].append(entry)
116
+ latest = _latest(trace, record['reviews'])
117
+ if len(json.dumps(record, indent=2, ensure_ascii=False).encode()) > MAX_REVIEW_BYTES:
118
+ raise ValueError('Review history exceeds the 8 MiB limit')
119
+ atomic_json(path, record)
120
+ return {'artifact': trace.identity(), 'review': entry, 'alignment_map': str(path),
121
+ 'active_review_count': len(latest), 'history_count': len(record['reviews']),
122
+ 'validation': 'agent_assertion_not_independently_verified', 'verdict': 'not_evaluated'}
123
+
124
+
125
+ def difference_candidates(root, artifact, cursor, limit, include_reviewed):
126
+ if type(cursor) is not int or cursor < 0 or type(limit) is not int or not 1 <= limit <= 50:
127
+ raise ValueError('cursor must be nonnegative and limit between 1 and 50')
128
+ if type(include_reviewed) is not bool:
129
+ raise ValueError('include_reviewed must be boolean')
130
+ trace = Trace(artifact)
131
+ latest = _latest(trace, _load(root, trace)['reviews'])
132
+ candidates, seen = [], set()
133
+ for i in sorted(trace.ops, key=lambda i: (str(trace.events[i]['args'].get('run')),
134
+ trace.events[i]['args'].get('rank', -1),
135
+ trace.events[i].get('ts', 0), i)):
136
+ if trace.events[i]['args']['status'] != 'diff':
137
+ continue
138
+ alignment = trace.aligned(i)
139
+ peer = alignment['peers'][0] if alignment['unique'] else None
140
+ if peer is None:
141
+ # Show a declared, uniquely addressable peer even when its automatic
142
+ # constraints fail, so the agent can reject that proposal. The
143
+ # alignment consistency failure remains visible alongside it.
144
+ claim = trace.events[i]['args'].get('correspondence') or {}
145
+ proposed = trace.records.get(claim.get('peer_record_id'), [])
146
+ if len(proposed) == 1 and trace.events[proposed[0]]['args'].get('run') != trace.events[i]['args'].get('run'):
147
+ peer = trace.compact(proposed[0])
148
+ key = _key(trace.ref(i), peer['event_ref']) if peer else (trace.ref(i),)
149
+ if key in seen:
150
+ continue
151
+ seen.add(key)
152
+ candidates.append({'left': trace.compact(i), 'right': peer, 'alignment': alignment,
153
+ 'diff': trace.events[i]['args'].get('diff'), 'review': latest.get(key)})
154
+ if cursor > len(candidates):
155
+ raise ValueError('cursor is beyond this artifact candidate queue')
156
+ selected, position = [], cursor
157
+ while position < len(candidates) and len(selected) < limit:
158
+ item = candidates[position]
159
+ position += 1
160
+ if include_reviewed or item['review'] is None:
161
+ selected.append(item)
162
+ return {'artifact': trace.identity(), 'candidates': selected,
163
+ 'next_cursor': position if position < len(candidates) else None,
164
+ 'candidate_count': len(candidates),
165
+ 'unreviewed_count': sum(item['review'] is None for item in candidates),
166
+ 'review_counts': dict(collections.Counter(r['decision'] for r in latest.values())),
167
+ 'alignment_map': str(_path(root, trace)) if latest else None,
168
+ 'scope': 'grouped_by_run_rank_export_order_not_global_causality',
169
+ 'verdict': 'not_evaluated',
170
+ 'note': 'Reviews do not erase observations. Use include_reviewed to revisit them; undetermined is unresolved. New declared pairs appear after render_alignment.'}
171
+
172
+
173
+ def render_alignment(root, artifact, output, backend):
174
+ """Freeze the current map and invoke the existing backend protocol, no workload."""
175
+ strings(backend, 'backend', True)
176
+ trace = Trace(artifact)
177
+ mapping = _load(root, trace)
178
+ if not mapping['reviews']:
179
+ raise ValueError('No reviews for this artifact')
180
+ target = Path(output).expanduser()
181
+ if not target.is_absolute() or target.exists():
182
+ raise ValueError('output must be a fresh absolute trace path')
183
+ directory = Path(root).expanduser().resolve() / 'review-renders' / uuid.uuid4().hex
184
+ directory.mkdir(parents=True)
185
+ map_path = directory / 'alignment-map.json'
186
+ atomic_json(map_path, mapping)
187
+ request = {'protocol_version': 1, 'artifact': str(trace.path),
188
+ 'alignment_map': str(map_path), 'output': str(target)}
189
+ atomic_json(directory / 'request.json', request)
190
+ env = {k: v for k, v in os.environ.items() if not k.startswith('OPGUARD_')}
191
+ env['OPGUARD_AUTOSTART'] = '0'
192
+ with (directory / 'response.json').open('w') as out, (directory / 'backend.log').open('w') as err:
193
+ process = subprocess.run([*backend, 'align', '--request', str(directory / 'request.json')],
194
+ stdout=out, stderr=err, env=env, timeout=120)
195
+ response = json.loads((directory / 'response.json').read_text())
196
+ if process.returncode or response.get('protocol_version') != 1 or response.get('ok') is not True:
197
+ raise RuntimeError(f'Alignment backend failed; inspect {directory}: {response.get("error")}')
198
+ result = response['result']
199
+ rendered = Trace(target)
200
+ if (Path(result['artifact']).resolve() != target.resolve() or result.get('sha256') != rendered.sha
201
+ or result.get('source_sha256') != trace.sha
202
+ or result.get('map_sha256') != hashlib.sha256(map_path.read_bytes()).hexdigest()
203
+ or result.get('verdict') != 'not_evaluated'):
204
+ raise RuntimeError(f'Alignment backend returned inconsistent provenance; inspect {directory}')
205
+ return {**result, 'render_directory': str(directory),
206
+ 'note': 'This is a new artifact revision. Obtain new event_refs; original trace and review history are preserved.'}
207
+
208
+
209
+ REVIEW_TOOLS = {fn.__name__: fn for fn in (review_pair, difference_candidates, render_alignment)}
@@ -0,0 +1,69 @@
1
+ """An explicit launch contract. No command guessing or shell interpolation."""
2
+ import copy
3
+ import math
4
+ from pathlib import Path
5
+
6
+ RESERVED_ENV = {
7
+ 'OPGUARD_AUTOSTART', 'OPGUARD_CALLSITE_TRACER', 'OPGUARD_TRACE_OUTPUT_BASE',
8
+ 'OPGUARD_LOCAL_DUMP_TARGET', 'OPGUARD_LOCAL_DUMP_ENABLED', 'OPGUARD_LOCAL_DUMP_BASE',
9
+ 'OPGUARD_CALLSITE_OUTPUT_DIR', 'OPGUARD_EXEC_OUTPUT_DIR', 'OPGUARD_CONFIG_PATH',
10
+ 'OPGUARD_CALLSITE_CONFIG', 'OPGUARD_RUN_ID', 'OPGUARD_RUN_TOKEN', 'OPGUARD_TRACE_ROOT',
11
+ 'DEFAULT_CODE_REPO_BASE',
12
+ }
13
+
14
+
15
+ def strings(value, name, nonempty=False):
16
+ if not isinstance(value, list) or (nonempty and not value) or any(
17
+ not isinstance(item, str) or '\0' in item for item in value):
18
+ raise ValueError(f'{name} must be an argv list of strings')
19
+ return value
20
+
21
+
22
+ def validate(spec):
23
+ spec = copy.deepcopy(spec)
24
+ allowed = {'baseline', 'candidate', 'backend', 'local_root', 'timeout_seconds',
25
+ 'max_stage_attempts', 'label', 'input_id'}
26
+ if not isinstance(spec, dict) or set(spec) - allowed:
27
+ raise ValueError('Unknown experiment fields')
28
+ for key in ('backend', 'local_root', 'baseline', 'candidate'):
29
+ if key not in spec:
30
+ raise ValueError(f'Missing {key}')
31
+ strings(spec['backend'], 'backend', True)
32
+ local = Path(spec['local_root']).expanduser()
33
+ if not local.is_absolute():
34
+ raise ValueError('local_root must be an absolute node-local path')
35
+ spec['local_root'] = str(local)
36
+ for name in ('baseline', 'candidate'):
37
+ workload = spec[name]
38
+ if not isinstance(workload, dict) or set(workload) - {'cwd', 'launcher', 'script', 'args', 'env', 'ranks'}:
39
+ raise ValueError(f'Unknown {name} workload fields')
40
+ cwd = Path(workload['cwd']).expanduser().resolve()
41
+ if not cwd.is_dir():
42
+ raise ValueError(f'{name}.cwd does not exist')
43
+ script = (cwd / workload['script']).resolve()
44
+ if cwd not in script.parents or not script.is_file():
45
+ raise ValueError(f'{name}.script must be a file inside its checkout')
46
+ workload.update(cwd=str(cwd), script=str(script))
47
+ strings(workload['launcher'], name + '.launcher', True)
48
+ strings(workload.setdefault('args', []), name + '.args')
49
+ env = workload.setdefault('env', {})
50
+ if not isinstance(env, dict) or any(not isinstance(k, str) or not isinstance(v, str)
51
+ or '=' in k or '\0' in k + v for k, v in env.items()):
52
+ raise ValueError('env must contain string keys and values')
53
+ if RESERVED_ENV & env.keys():
54
+ raise ValueError('Output/lifecycle configuration belongs to the harness, not workload.env')
55
+ ranks = workload.setdefault('ranks', [0])
56
+ if not isinstance(ranks, list) or not ranks or any(type(r) is not int or r < 0 for r in ranks) or len(set(ranks)) != len(ranks):
57
+ raise ValueError('ranks must be distinct nonnegative integers')
58
+ workload['ranks'] = sorted(ranks)
59
+ if spec['baseline']['cwd'] == spec['candidate']['cwd']:
60
+ raise ValueError('Use separate source checkouts for baseline and candidate')
61
+ if spec['baseline']['ranks'] != spec['candidate']['ranks']:
62
+ raise ValueError('v1 compares matching global ranks; rank remapping is not supported')
63
+ timeout = spec.setdefault('timeout_seconds', 3600)
64
+ attempts = spec.setdefault('max_stage_attempts', 2)
65
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
66
+ raise ValueError('timeout_seconds must be positive and finite')
67
+ if type(attempts) is not int or not 1 <= attempts <= 10:
68
+ raise ValueError('max_stage_attempts must be between 1 and 10')
69
+ return spec