agent-apprenticeship 0.1.8 → 0.2.0
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.
- package/bin/agent-apprenticeship.js +96 -22
- package/docs/bioactive_evidence_mapping_schema.md +113 -0
- package/docs/dataset_schema.md +160 -0
- package/docs/local_usage.md +191 -0
- package/docs/scaling_to_1000_tasks.md +3 -0
- package/docs/trace_schema.md +192 -0
- package/docs/v0.2_release_notes.md +61 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/agent_apprenticeship_trace/__init__.py +1 -1
- package/src/agent_apprenticeship_trace/bundle_exporter.py +36 -1
- package/src/agent_apprenticeship_trace/cli.py +73 -12
- package/src/agent_apprenticeship_trace/contract_verifier.py +686 -0
- package/src/agent_apprenticeship_trace/ecosystem_r2.py +2 -1
- package/src/agent_apprenticeship_trace/learning.py +203 -6
- package/src/agent_apprenticeship_trace/public_run.py +31 -3
- package/src/agent_apprenticeship_trace/public_sanitizer.py +3 -3
- package/src/agent_apprenticeship_trace/tdo.py +1323 -44
- package/src/agent_apprenticeship_trace/trace_normalizer.py +25 -2
- package/src/agent_apprenticeship_trace/version.py +15 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _read_json(path: Path) -> Any:
|
|
10
|
+
return json.loads(path.read_text(errors="replace"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _as_list(value: Any) -> list[Any]:
|
|
14
|
+
if value is None:
|
|
15
|
+
return []
|
|
16
|
+
return value if isinstance(value, list) else [value]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _field_names(row: dict[str, Any]) -> list[str]:
|
|
20
|
+
fields: list[str] = []
|
|
21
|
+
for key in ("fields", "columns", "keys", "required_fields", "required_columns", "required_keys", "sections"):
|
|
22
|
+
for value in _as_list(row.get(key)):
|
|
23
|
+
if isinstance(value, str) and value and value not in fields:
|
|
24
|
+
fields.append(value)
|
|
25
|
+
return fields
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _numeric_fields(row: dict[str, Any]) -> list[str]:
|
|
29
|
+
fields: list[str] = []
|
|
30
|
+
for key in ("numeric_fields", "numeric_columns", "required_numeric_fields", "required_numeric_columns"):
|
|
31
|
+
for value in _as_list(row.get(key)):
|
|
32
|
+
if isinstance(value, str) and value and value not in fields:
|
|
33
|
+
fields.append(value)
|
|
34
|
+
return fields
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _non_empty_fields(row: dict[str, Any]) -> list[str]:
|
|
38
|
+
fields: list[str] = []
|
|
39
|
+
for key in ("non_empty_fields", "non_empty_columns", "required_non_empty_fields", "required_non_empty_columns"):
|
|
40
|
+
for value in _as_list(row.get(key)):
|
|
41
|
+
if isinstance(value, str) and value and value not in fields:
|
|
42
|
+
fields.append(value)
|
|
43
|
+
return fields
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _expected_type_map(row: dict[str, Any]) -> dict[str, str]:
|
|
47
|
+
value = row.get("expected_types") or row.get("field_types") or row.get("column_types") or {}
|
|
48
|
+
return {str(k): str(v).lower() for k, v in value.items()} if isinstance(value, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _list_item_type_map(row: dict[str, Any]) -> dict[str, str]:
|
|
52
|
+
value = row.get("list_item_types") or row.get("array_item_types") or {}
|
|
53
|
+
return {str(k): str(v).lower() for k, v in value.items()} if isinstance(value, dict) else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _semantic_requirements(row: dict[str, Any]) -> list[str]:
|
|
57
|
+
requirements: list[str] = []
|
|
58
|
+
for key in (
|
|
59
|
+
"semantic_checks",
|
|
60
|
+
"scoring_critical_requirements",
|
|
61
|
+
"scoring_critical_rules",
|
|
62
|
+
"objective_checks",
|
|
63
|
+
"value_checks",
|
|
64
|
+
):
|
|
65
|
+
for value in _as_list(row.get(key)):
|
|
66
|
+
if isinstance(value, str) and value.strip() and value.strip() not in requirements:
|
|
67
|
+
requirements.append(value.strip())
|
|
68
|
+
return requirements
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _path_value(row: dict[str, Any]) -> str | None:
|
|
72
|
+
for key in ("path", "filename", "artifact", "artifact_path", "artifact_ref", "output_file"):
|
|
73
|
+
value = row.get(key)
|
|
74
|
+
if isinstance(value, str) and value.strip():
|
|
75
|
+
return value.strip().replace("\\", "/")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _hidden_or_forbidden_ref(rel: str) -> bool:
|
|
80
|
+
parts = {part.lower() for part in Path(rel.replace("\\", "/")).parts}
|
|
81
|
+
return "expected" in parts or "hidden" in parts or rel.startswith("/") or ".." in Path(rel).parts
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _candidate_paths(base: Path, rel: str) -> list[Path]:
|
|
85
|
+
rel = rel.lstrip("./")
|
|
86
|
+
paths = [base / rel]
|
|
87
|
+
if rel.startswith("output/"):
|
|
88
|
+
paths.append(base / rel.removeprefix("output/"))
|
|
89
|
+
else:
|
|
90
|
+
paths.append(base / "output" / rel)
|
|
91
|
+
paths.append(base / "artifacts" / rel)
|
|
92
|
+
return list(dict.fromkeys(paths))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _coerce_number(value: Any) -> float | None:
|
|
96
|
+
try:
|
|
97
|
+
if value in (None, ""):
|
|
98
|
+
return None
|
|
99
|
+
return float(str(value))
|
|
100
|
+
except Exception:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_json_fields(path: Path, fields: list[str]) -> list[str]:
|
|
105
|
+
try:
|
|
106
|
+
data = _read_json(path)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
return [f"{path.name} is not valid JSON: {type(exc).__name__}"]
|
|
109
|
+
if not fields:
|
|
110
|
+
return []
|
|
111
|
+
if isinstance(data, dict):
|
|
112
|
+
missing = [field for field in fields if field not in data]
|
|
113
|
+
return [f"{path.name} missing JSON field {field}" for field in missing]
|
|
114
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
115
|
+
missing = [field for field in fields if field not in data[0]]
|
|
116
|
+
return [f"{path.name} first row missing JSON field {field}" for field in missing]
|
|
117
|
+
return [f"{path.name} cannot be checked for JSON object fields"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _json_data_at_path(path: Path) -> Any:
|
|
121
|
+
try:
|
|
122
|
+
return _read_json(path)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _lookup_path(data: Any, field_path: str) -> tuple[bool, Any]:
|
|
128
|
+
current = data
|
|
129
|
+
for part in str(field_path).split("."):
|
|
130
|
+
if part == "":
|
|
131
|
+
continue
|
|
132
|
+
if isinstance(current, dict):
|
|
133
|
+
if part not in current:
|
|
134
|
+
return False, None
|
|
135
|
+
current = current[part]
|
|
136
|
+
elif isinstance(current, list):
|
|
137
|
+
try:
|
|
138
|
+
current = current[int(part)]
|
|
139
|
+
except Exception:
|
|
140
|
+
return False, None
|
|
141
|
+
else:
|
|
142
|
+
return False, None
|
|
143
|
+
return True, current
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _type_matches(value: Any, expected_type: str) -> bool:
|
|
147
|
+
expected_type = expected_type.lower()
|
|
148
|
+
if expected_type in {"number", "numeric", "float"}:
|
|
149
|
+
return _coerce_number(value) is not None
|
|
150
|
+
if expected_type in {"int", "integer"}:
|
|
151
|
+
number = _coerce_number(value)
|
|
152
|
+
return number is not None and float(number).is_integer()
|
|
153
|
+
if expected_type in {"string", "str"}:
|
|
154
|
+
return isinstance(value, str)
|
|
155
|
+
if expected_type in {"boolean", "bool"}:
|
|
156
|
+
return isinstance(value, bool)
|
|
157
|
+
if expected_type in {"array", "list"}:
|
|
158
|
+
return isinstance(value, list)
|
|
159
|
+
if expected_type in {"object", "dict"}:
|
|
160
|
+
return isinstance(value, dict)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_csv_fields(path: Path, fields: list[str], delimiter: str = ",") -> list[str]:
|
|
165
|
+
try:
|
|
166
|
+
with path.open(newline="", errors="replace") as handle:
|
|
167
|
+
reader = csv.reader(handle, delimiter=delimiter)
|
|
168
|
+
header = next(reader, [])
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
return [f"{path.name} is not valid delimited text: {type(exc).__name__}"]
|
|
171
|
+
missing = [field for field in fields if field not in header]
|
|
172
|
+
return [f"{path.name} missing column {field}" for field in missing]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _csv_rows(path: Path, delimiter: str = ",") -> tuple[list[str], list[dict[str, str]]]:
|
|
176
|
+
with path.open(newline="", errors="replace") as handle:
|
|
177
|
+
reader = csv.DictReader(handle, delimiter=delimiter)
|
|
178
|
+
header = list(reader.fieldnames or [])
|
|
179
|
+
return header, list(reader)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _derived_value_failures(base: Path, item: dict[str, Any], rel: str, output_rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
183
|
+
failures: list[dict[str, Any]] = []
|
|
184
|
+
for check in _as_list(item.get("derived_value_checks")):
|
|
185
|
+
if not isinstance(check, dict):
|
|
186
|
+
continue
|
|
187
|
+
operation = str(check.get("operation") or "").lower()
|
|
188
|
+
if operation != "group_mean_difference":
|
|
189
|
+
continue
|
|
190
|
+
input_csv = check.get("input_csv") or check.get("input_expression_matrix")
|
|
191
|
+
metadata_csv = check.get("metadata_csv") or check.get("input_metadata")
|
|
192
|
+
if not isinstance(input_csv, str) or not isinstance(metadata_csv, str):
|
|
193
|
+
continue
|
|
194
|
+
input_path = next((candidate for candidate in _candidate_paths(base, input_csv) if candidate.exists()), None)
|
|
195
|
+
metadata_path = next((candidate for candidate in _candidate_paths(base, metadata_csv) if candidate.exists()), None)
|
|
196
|
+
if not input_path or not metadata_path:
|
|
197
|
+
failures.append(
|
|
198
|
+
{
|
|
199
|
+
"path": rel,
|
|
200
|
+
"kind": "output",
|
|
201
|
+
"issue": f"derived_check_inputs_missing:{check.get('check_id') or operation}",
|
|
202
|
+
"repair_hint": "Run the visible-input derived-value check only after input CSV and metadata CSV are present.",
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
_meta_header, meta_rows = _csv_rows(metadata_path)
|
|
208
|
+
_expr_header, expr_rows = _csv_rows(input_path)
|
|
209
|
+
except Exception:
|
|
210
|
+
continue
|
|
211
|
+
sample_column = str(check.get("sample_column") or "sample")
|
|
212
|
+
group_column = str(check.get("group_column") or "group")
|
|
213
|
+
group_a = str(check.get("group_a") or check.get("normal_group") or "normal")
|
|
214
|
+
group_b = str(check.get("group_b") or check.get("tumor_group") or "tumor")
|
|
215
|
+
key_column = str(check.get("key_column") or check.get("gene_column") or "gene")
|
|
216
|
+
output_key = str(check.get("output_key_column") or key_column)
|
|
217
|
+
output_a = str(check.get("output_group_a_mean_column") or check.get("normal_mean_column") or "mean_normal")
|
|
218
|
+
output_b = str(check.get("output_group_b_mean_column") or check.get("tumor_mean_column") or "mean_tumor")
|
|
219
|
+
output_delta = str(check.get("output_delta_column") or check.get("target_column") or "log2FC")
|
|
220
|
+
tolerance = float(check.get("tolerance") or 1e-3)
|
|
221
|
+
samples_a = [row.get(sample_column) for row in meta_rows if str(row.get(group_column) or "").lower() == group_a.lower()]
|
|
222
|
+
samples_b = [row.get(sample_column) for row in meta_rows if str(row.get(group_column) or "").lower() == group_b.lower()]
|
|
223
|
+
expected_by_key: dict[str, tuple[float, float, float]] = {}
|
|
224
|
+
for expr in expr_rows:
|
|
225
|
+
key = str(expr.get(key_column) or "")
|
|
226
|
+
values_a = [_coerce_number(expr.get(str(sample))) for sample in samples_a if sample]
|
|
227
|
+
values_b = [_coerce_number(expr.get(str(sample))) for sample in samples_b if sample]
|
|
228
|
+
values_a = [value for value in values_a if value is not None]
|
|
229
|
+
values_b = [value for value in values_b if value is not None]
|
|
230
|
+
if not key or not values_a or not values_b:
|
|
231
|
+
continue
|
|
232
|
+
mean_a = sum(values_a) / len(values_a)
|
|
233
|
+
mean_b = sum(values_b) / len(values_b)
|
|
234
|
+
expected_by_key[key] = (mean_a, mean_b, mean_b - mean_a)
|
|
235
|
+
for output in output_rows:
|
|
236
|
+
key = str(output.get(output_key) or "")
|
|
237
|
+
if key not in expected_by_key:
|
|
238
|
+
continue
|
|
239
|
+
mean_a, mean_b, delta = expected_by_key[key]
|
|
240
|
+
checks = [(output_a, mean_a), (output_b, mean_b), (output_delta, delta)]
|
|
241
|
+
for field, expected in checks:
|
|
242
|
+
observed = _coerce_number(output.get(field))
|
|
243
|
+
if observed is None or abs(observed - expected) > tolerance:
|
|
244
|
+
failures.append(
|
|
245
|
+
{
|
|
246
|
+
"path": rel,
|
|
247
|
+
"kind": "output",
|
|
248
|
+
"issue": f"derived_value_mismatch:{field}",
|
|
249
|
+
"repair_hint": f"Recompute {field} in {rel} from visible input files using the declared group-mean difference rule.",
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
break
|
|
253
|
+
if failures:
|
|
254
|
+
break
|
|
255
|
+
return failures
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _actual_outputs_file_refs(data: Any) -> set[str]:
|
|
259
|
+
refs: set[str] = set()
|
|
260
|
+
|
|
261
|
+
def add(value: Any) -> None:
|
|
262
|
+
if isinstance(value, str) and value.strip():
|
|
263
|
+
text = value.strip().replace("\\", "/")
|
|
264
|
+
refs.add(text)
|
|
265
|
+
refs.add(text.removeprefix("output/"))
|
|
266
|
+
refs.add(Path(text).name)
|
|
267
|
+
elif isinstance(value, dict):
|
|
268
|
+
for key in ("path", "ref", "file", "filename", "artifact_ref", "package_relative_path"):
|
|
269
|
+
add(value.get(key))
|
|
270
|
+
|
|
271
|
+
if not isinstance(data, dict):
|
|
272
|
+
return refs
|
|
273
|
+
for key in (
|
|
274
|
+
"files",
|
|
275
|
+
"files_created",
|
|
276
|
+
"deliverable_refs",
|
|
277
|
+
"artifact_refs",
|
|
278
|
+
"output_refs",
|
|
279
|
+
"generated_files",
|
|
280
|
+
"created_files",
|
|
281
|
+
):
|
|
282
|
+
for value in _as_list(data.get(key)):
|
|
283
|
+
add(value)
|
|
284
|
+
for key in ("primary_output_ref", "actual_outputs_ref"):
|
|
285
|
+
add(data.get(key))
|
|
286
|
+
return refs
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def normalize_contract(contract: dict[str, Any] | None) -> dict[str, Any]:
|
|
290
|
+
contract = dict(contract or {})
|
|
291
|
+
outputs: list[dict[str, Any]] = []
|
|
292
|
+
artifacts: list[dict[str, Any]] = []
|
|
293
|
+
for key in ("outputs", "required_outputs", "required_output_files", "files"):
|
|
294
|
+
for item in _as_list(contract.get(key)):
|
|
295
|
+
if isinstance(item, str):
|
|
296
|
+
outputs.append({"path": item})
|
|
297
|
+
elif isinstance(item, dict):
|
|
298
|
+
outputs.append(dict(item))
|
|
299
|
+
for key in ("artifacts", "required_artifacts", "artifact_contracts"):
|
|
300
|
+
for item in _as_list(contract.get(key)):
|
|
301
|
+
if isinstance(item, str):
|
|
302
|
+
artifacts.append({"path": item})
|
|
303
|
+
elif isinstance(item, dict):
|
|
304
|
+
artifacts.append(dict(item))
|
|
305
|
+
if not outputs and not artifacts and _path_value(contract):
|
|
306
|
+
outputs.append(contract)
|
|
307
|
+
return {"outputs": outputs, "artifacts": artifacts}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def verify_output_contract(base: Path, contract: dict[str, Any] | None, *, max_failures: int = 50) -> dict[str, Any]:
|
|
311
|
+
base = base.expanduser()
|
|
312
|
+
normalized = normalize_contract(contract)
|
|
313
|
+
failures: list[dict[str, Any]] = []
|
|
314
|
+
checked: list[dict[str, Any]] = []
|
|
315
|
+
semantic_warnings: list[dict[str, Any]] = []
|
|
316
|
+
required_refs: list[str] = []
|
|
317
|
+
|
|
318
|
+
def check_item(item: dict[str, Any], kind: str) -> None:
|
|
319
|
+
rel = _path_value(item)
|
|
320
|
+
if not rel:
|
|
321
|
+
return
|
|
322
|
+
if _hidden_or_forbidden_ref(rel):
|
|
323
|
+
failures.append(
|
|
324
|
+
{
|
|
325
|
+
"path": rel,
|
|
326
|
+
"kind": kind,
|
|
327
|
+
"issue": "forbidden_or_hidden_reference",
|
|
328
|
+
"repair_hint": "Use visible task inputs and output paths only; do not read expected or hidden reference files.",
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
required_refs.append(rel)
|
|
333
|
+
candidates = _candidate_paths(base, rel)
|
|
334
|
+
path = next((candidate for candidate in candidates if candidate.exists() and candidate.is_file()), None)
|
|
335
|
+
fields = _field_names(item)
|
|
336
|
+
semantic_requirements = _semantic_requirements(item)
|
|
337
|
+
entry = {
|
|
338
|
+
"kind": kind,
|
|
339
|
+
"path": rel,
|
|
340
|
+
"exists": bool(path),
|
|
341
|
+
"fields": fields,
|
|
342
|
+
"semantic_requirements": semantic_requirements,
|
|
343
|
+
}
|
|
344
|
+
checked.append(entry)
|
|
345
|
+
for requirement in semantic_requirements:
|
|
346
|
+
semantic_warnings.append(
|
|
347
|
+
{
|
|
348
|
+
"path": rel,
|
|
349
|
+
"kind": kind,
|
|
350
|
+
"requirement": requirement,
|
|
351
|
+
"status": "requires_semantic_or_grader_check",
|
|
352
|
+
"repair_hint": "Use task rubric, visible inputs, and grader/verifier feedback to check this requirement; do not infer hidden answers.",
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
if not path:
|
|
356
|
+
failures.append({"path": rel, "kind": kind, "issue": "missing_file", "repair_hint": f"Create {rel} in the required output location."})
|
|
357
|
+
return
|
|
358
|
+
if path.stat().st_size == 0:
|
|
359
|
+
failures.append({"path": rel, "kind": kind, "issue": "empty_file", "repair_hint": f"Regenerate {rel}; it is empty."})
|
|
360
|
+
return
|
|
361
|
+
suffix = path.suffix.lower()
|
|
362
|
+
issues: list[str] = []
|
|
363
|
+
fmt = str(item.get("format") or "").lower()
|
|
364
|
+
if suffix == ".json" or fmt == "json":
|
|
365
|
+
issues = _check_json_fields(path, fields)
|
|
366
|
+
elif suffix == ".csv" or fmt == "csv":
|
|
367
|
+
issues = _check_csv_fields(path, fields)
|
|
368
|
+
elif suffix in {".tsv", ".bed"} or fmt in {"tsv", "bed"}:
|
|
369
|
+
issues = _check_csv_fields(path, fields, delimiter="\t") if fields else []
|
|
370
|
+
for issue in issues:
|
|
371
|
+
failures.append({"path": rel, "kind": kind, "issue": issue, "repair_hint": f"Repair {rel} so it matches the required schema."})
|
|
372
|
+
|
|
373
|
+
for item in normalized["outputs"]:
|
|
374
|
+
check_item(item, "output")
|
|
375
|
+
for item in normalized["artifacts"]:
|
|
376
|
+
check_item(item, "artifact")
|
|
377
|
+
|
|
378
|
+
actual_outputs_candidates = _candidate_paths(base, "output/actual_outputs.json") + _candidate_paths(base, "actual_outputs.json")
|
|
379
|
+
actual_outputs_path = next((candidate for candidate in actual_outputs_candidates if candidate.exists() and candidate.is_file()), None)
|
|
380
|
+
actual_outputs_ok = False
|
|
381
|
+
actual_outputs_file_refs: set[str] = set()
|
|
382
|
+
if actual_outputs_path:
|
|
383
|
+
try:
|
|
384
|
+
data = _read_json(actual_outputs_path)
|
|
385
|
+
actual_outputs_ok = isinstance(data, dict)
|
|
386
|
+
actual_outputs_file_refs = _actual_outputs_file_refs(data)
|
|
387
|
+
except Exception:
|
|
388
|
+
failures.append({"path": "actual_outputs.json", "kind": "output", "issue": "invalid_actual_outputs_json", "repair_hint": "Rewrite actual_outputs.json as valid JSON."})
|
|
389
|
+
else:
|
|
390
|
+
failures.append({"path": "actual_outputs.json", "kind": "output", "issue": "missing_actual_outputs_json", "repair_hint": "Create actual_outputs.json with generated file references."})
|
|
391
|
+
|
|
392
|
+
if actual_outputs_ok:
|
|
393
|
+
for rel in required_refs:
|
|
394
|
+
if Path(rel).name == "actual_outputs.json":
|
|
395
|
+
continue
|
|
396
|
+
normalized_refs = {rel, rel.removeprefix("output/"), Path(rel).name}
|
|
397
|
+
if actual_outputs_file_refs and not (normalized_refs & actual_outputs_file_refs):
|
|
398
|
+
failures.append(
|
|
399
|
+
{
|
|
400
|
+
"path": rel,
|
|
401
|
+
"kind": "output",
|
|
402
|
+
"issue": "actual_outputs_json_missing_required_file_ref",
|
|
403
|
+
"repair_hint": f"Update actual_outputs.json so it lists {rel}.",
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
elif not actual_outputs_file_refs:
|
|
407
|
+
failures.append(
|
|
408
|
+
{
|
|
409
|
+
"path": rel,
|
|
410
|
+
"kind": "output",
|
|
411
|
+
"issue": "actual_outputs_json_has_no_file_refs",
|
|
412
|
+
"repair_hint": "Populate actual_outputs.json files/files_created/deliverable_refs with generated file paths.",
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
actual_outputs_data = _read_json(actual_outputs_path) if actual_outputs_path else {}
|
|
417
|
+
for item in normalized["outputs"]:
|
|
418
|
+
rel = _path_value(item)
|
|
419
|
+
if not rel or Path(rel).name != "actual_outputs.json":
|
|
420
|
+
continue
|
|
421
|
+
for required_file in _as_list(item.get("actual_outputs_required_files")):
|
|
422
|
+
if isinstance(required_file, str):
|
|
423
|
+
normalized_required = {required_file, required_file.removeprefix("output/"), Path(required_file).name}
|
|
424
|
+
if not (normalized_required & actual_outputs_file_refs):
|
|
425
|
+
failures.append(
|
|
426
|
+
{
|
|
427
|
+
"path": rel,
|
|
428
|
+
"kind": "output",
|
|
429
|
+
"issue": "actual_outputs_json_missing_declared_file",
|
|
430
|
+
"repair_hint": f"Update actual_outputs.json so it lists {required_file}.",
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
for field, expected_item_type in _list_item_type_map(item).items():
|
|
434
|
+
exists, value = _lookup_path(actual_outputs_data, field)
|
|
435
|
+
if not exists or not isinstance(value, list):
|
|
436
|
+
continue
|
|
437
|
+
for index, entry in enumerate(value):
|
|
438
|
+
if not _type_matches(entry, expected_item_type):
|
|
439
|
+
failures.append(
|
|
440
|
+
{
|
|
441
|
+
"path": rel,
|
|
442
|
+
"kind": "output",
|
|
443
|
+
"issue": f"actual_outputs_json_list_item_type_mismatch:{field}.{index}",
|
|
444
|
+
"repair_hint": f"Rewrite {field} entries in actual_outputs.json as {expected_item_type} values.",
|
|
445
|
+
}
|
|
446
|
+
)
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
failures = failures[:max_failures]
|
|
450
|
+
return {
|
|
451
|
+
"contract_verification_passed": not failures,
|
|
452
|
+
"actual_outputs_json_ok": actual_outputs_ok,
|
|
453
|
+
"checked": checked,
|
|
454
|
+
"failures": failures,
|
|
455
|
+
"failure_count": len(failures),
|
|
456
|
+
"semantic_warnings": semantic_warnings[:max_failures],
|
|
457
|
+
"semantic_warning_count": len(semantic_warnings),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def semantic_verify_outputs(
|
|
462
|
+
base: Path,
|
|
463
|
+
contract: dict[str, Any] | None,
|
|
464
|
+
*,
|
|
465
|
+
source_contract: dict[str, Any] | None = None,
|
|
466
|
+
max_failures: int = 50,
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
"""Visible-evidence semantic checks for scoring-critical output contracts.
|
|
469
|
+
|
|
470
|
+
This intentionally does not read expected/ or hidden reference files. It checks
|
|
471
|
+
declared schema/type/manifest invariants and reports uncertain scorer-critical
|
|
472
|
+
checks as targeted warnings rather than pretending to know hidden answers.
|
|
473
|
+
"""
|
|
474
|
+
base = base.expanduser()
|
|
475
|
+
structural = verify_output_contract(base, contract, max_failures=max_failures)
|
|
476
|
+
normalized = normalize_contract(contract)
|
|
477
|
+
source_normalized = normalize_contract(source_contract)
|
|
478
|
+
failures: list[dict[str, Any]] = list(structural.get("failures") or [])
|
|
479
|
+
warnings: list[dict[str, Any]] = list(structural.get("semantic_warnings") or [])
|
|
480
|
+
visible_checks_used = [
|
|
481
|
+
"required_file_existence",
|
|
482
|
+
"json_parse",
|
|
483
|
+
"csv_tsv_header_check",
|
|
484
|
+
"actual_outputs_file_refs",
|
|
485
|
+
"declared_numeric_fields",
|
|
486
|
+
"declared_non_empty_fields",
|
|
487
|
+
"declared_row_count_bounds",
|
|
488
|
+
"target_source_contract_conflict",
|
|
489
|
+
]
|
|
490
|
+
checked_paths: set[str] = set()
|
|
491
|
+
|
|
492
|
+
def add_failure(rel: str, issue: str, repair_hint: str, *, kind: str = "output") -> None:
|
|
493
|
+
failures.append({"path": rel, "kind": kind, "issue": issue, "repair_hint": repair_hint})
|
|
494
|
+
|
|
495
|
+
def check_item(item: dict[str, Any], kind: str) -> None:
|
|
496
|
+
rel = _path_value(item)
|
|
497
|
+
if not rel or _hidden_or_forbidden_ref(rel):
|
|
498
|
+
return
|
|
499
|
+
checked_paths.add(rel)
|
|
500
|
+
path = next((candidate for candidate in _candidate_paths(base, rel) if candidate.exists() and candidate.is_file()), None)
|
|
501
|
+
if not path:
|
|
502
|
+
return
|
|
503
|
+
fmt = str(item.get("format") or path.suffix.lstrip(".")).lower()
|
|
504
|
+
numeric_fields = _numeric_fields(item)
|
|
505
|
+
non_empty_fields = _non_empty_fields(item)
|
|
506
|
+
expected_types = _expected_type_map(item)
|
|
507
|
+
list_item_types = _list_item_type_map(item)
|
|
508
|
+
min_rows = item.get("min_rows")
|
|
509
|
+
max_rows = item.get("max_rows")
|
|
510
|
+
exact_rows = item.get("row_count") or item.get("expected_row_count")
|
|
511
|
+
min_size = int(item.get("min_bytes") or 1)
|
|
512
|
+
if path.stat().st_size < min_size:
|
|
513
|
+
add_failure(rel, "artifact_too_small_or_empty", f"Regenerate {rel}; it is too small to satisfy the declared artifact contract.", kind=kind)
|
|
514
|
+
return
|
|
515
|
+
if fmt == "json" or path.suffix.lower() == ".json":
|
|
516
|
+
data = _json_data_at_path(path)
|
|
517
|
+
if data is None:
|
|
518
|
+
return
|
|
519
|
+
rows = data if isinstance(data, list) else [data]
|
|
520
|
+
if exact_rows is not None and isinstance(data, list) and len(data) != int(exact_rows):
|
|
521
|
+
add_failure(rel, "json_row_count_mismatch", f"Adjust {rel} so it has exactly {exact_rows} rows/items.", kind=kind)
|
|
522
|
+
if min_rows is not None and isinstance(data, list) and len(data) < int(min_rows):
|
|
523
|
+
add_failure(rel, "json_row_count_below_minimum", f"Add the missing rows/items to {rel}; expected at least {min_rows}.", kind=kind)
|
|
524
|
+
if max_rows is not None and isinstance(data, list) and len(data) > int(max_rows):
|
|
525
|
+
add_failure(rel, "json_row_count_above_maximum", f"Reduce {rel} to at most {max_rows} rows/items.", kind=kind)
|
|
526
|
+
for row in rows:
|
|
527
|
+
if not isinstance(row, dict):
|
|
528
|
+
continue
|
|
529
|
+
for field in non_empty_fields:
|
|
530
|
+
exists, value = _lookup_path(row, field)
|
|
531
|
+
if not exists or value in (None, "", [], {}):
|
|
532
|
+
add_failure(rel, f"json_field_empty:{field}", f"Populate JSON field {field} in {rel}.", kind=kind)
|
|
533
|
+
for field in numeric_fields:
|
|
534
|
+
exists, value = _lookup_path(row, field)
|
|
535
|
+
if not exists or _coerce_number(value) is None:
|
|
536
|
+
add_failure(rel, f"json_field_not_numeric:{field}", f"Write numeric values for JSON field {field} in {rel}.", kind=kind)
|
|
537
|
+
for field, expected_type in expected_types.items():
|
|
538
|
+
exists, value = _lookup_path(row, field)
|
|
539
|
+
if not exists or not _type_matches(value, expected_type):
|
|
540
|
+
add_failure(rel, f"json_field_type_mismatch:{field}", f"Write {field} in {rel} as {expected_type}.", kind=kind)
|
|
541
|
+
for field, expected_item_type in list_item_types.items():
|
|
542
|
+
exists, value = _lookup_path(row, field)
|
|
543
|
+
if not exists or not isinstance(value, list):
|
|
544
|
+
continue
|
|
545
|
+
for index, entry in enumerate(value):
|
|
546
|
+
if not _type_matches(entry, expected_item_type):
|
|
547
|
+
add_failure(rel, f"json_list_item_type_mismatch:{field}.{index}", f"Write {field} list entries in {rel} as {expected_item_type} values.", kind=kind)
|
|
548
|
+
break
|
|
549
|
+
elif fmt in {"csv", "tsv", "bed"} or path.suffix.lower() in {".csv", ".tsv", ".bed"}:
|
|
550
|
+
delimiter = "\t" if fmt in {"tsv", "bed"} or path.suffix.lower() in {".tsv", ".bed"} else ","
|
|
551
|
+
try:
|
|
552
|
+
_header, rows = _csv_rows(path, delimiter=delimiter)
|
|
553
|
+
except Exception:
|
|
554
|
+
return
|
|
555
|
+
if exact_rows is not None and len(rows) != int(exact_rows):
|
|
556
|
+
add_failure(rel, "csv_row_count_mismatch", f"Adjust {rel} so it has exactly {exact_rows} data rows.", kind=kind)
|
|
557
|
+
if min_rows is not None and len(rows) < int(min_rows):
|
|
558
|
+
add_failure(rel, "csv_row_count_below_minimum", f"Add the missing rows to {rel}; expected at least {min_rows}.", kind=kind)
|
|
559
|
+
if max_rows is not None and len(rows) > int(max_rows):
|
|
560
|
+
add_failure(rel, "csv_row_count_above_maximum", f"Reduce {rel} to at most {max_rows} data rows.", kind=kind)
|
|
561
|
+
if not rows and item.get("scoring_critical", True):
|
|
562
|
+
add_failure(rel, "csv_has_no_data_rows", f"Populate {rel} with data rows, not just a header.", kind=kind)
|
|
563
|
+
for row in rows:
|
|
564
|
+
for field in non_empty_fields:
|
|
565
|
+
if row.get(field) in (None, ""):
|
|
566
|
+
add_failure(rel, f"csv_field_empty:{field}", f"Populate CSV column {field} in {rel}.", kind=kind)
|
|
567
|
+
for field in numeric_fields:
|
|
568
|
+
if _coerce_number(row.get(field)) is None:
|
|
569
|
+
add_failure(rel, f"csv_field_not_numeric:{field}", f"Write numeric values for CSV column {field} in {rel}.", kind=kind)
|
|
570
|
+
for field, expected_type in expected_types.items():
|
|
571
|
+
value = row.get(field)
|
|
572
|
+
if expected_type in {"number", "numeric", "float", "int", "integer"} and _coerce_number(value) is None:
|
|
573
|
+
add_failure(rel, f"csv_field_type_mismatch:{field}", f"Write {field} in {rel} as {expected_type}.", kind=kind)
|
|
574
|
+
for failure in _derived_value_failures(base, item, rel, rows):
|
|
575
|
+
failures.append(failure)
|
|
576
|
+
|
|
577
|
+
for item in normalized["outputs"]:
|
|
578
|
+
check_item(item, "output")
|
|
579
|
+
for item in normalized["artifacts"]:
|
|
580
|
+
check_item(item, "artifact")
|
|
581
|
+
|
|
582
|
+
if source_normalized["outputs"] and normalized["outputs"]:
|
|
583
|
+
target_paths = {_path_value(item) for item in normalized["outputs"] if _path_value(item)}
|
|
584
|
+
source_paths = {_path_value(item) for item in source_normalized["outputs"] if _path_value(item)}
|
|
585
|
+
source_only = sorted(path for path in source_paths - target_paths if path and not _hidden_or_forbidden_ref(path))
|
|
586
|
+
for path in source_only[:10]:
|
|
587
|
+
warnings.append(
|
|
588
|
+
{
|
|
589
|
+
"path": path,
|
|
590
|
+
"kind": "source_contract",
|
|
591
|
+
"requirement": "source_contract_path_not_in_target_contract",
|
|
592
|
+
"status": "do_not_copy_source_output_contract_blindly",
|
|
593
|
+
"repair_hint": "Follow the target mirror contract; do not add source-only output paths unless the target task asks for them.",
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
unique_failures: list[dict[str, Any]] = []
|
|
598
|
+
seen = set()
|
|
599
|
+
for failure in failures:
|
|
600
|
+
key = (failure.get("path"), failure.get("issue"))
|
|
601
|
+
if key not in seen:
|
|
602
|
+
seen.add(key)
|
|
603
|
+
unique_failures.append(failure)
|
|
604
|
+
unique_failures = unique_failures[:max_failures]
|
|
605
|
+
status = "fail" if unique_failures else "uncertain" if warnings else "pass"
|
|
606
|
+
repair_allowed = bool(unique_failures) and status == "fail" and len({f.get("path") for f in unique_failures}) <= 3
|
|
607
|
+
return {
|
|
608
|
+
"semantic_verification_status": status,
|
|
609
|
+
"scorer_critical_failures": unique_failures,
|
|
610
|
+
"non_blocking_warnings": warnings[:max_failures],
|
|
611
|
+
"evidence_refs": sorted(checked_paths),
|
|
612
|
+
"visible_checks_used": visible_checks_used,
|
|
613
|
+
"hidden_reference_used": False,
|
|
614
|
+
"repair_allowed": repair_allowed,
|
|
615
|
+
"repair_scope": sorted({str(f.get("path")) for f in unique_failures if f.get("path")}),
|
|
616
|
+
"do_not_modify": sorted(checked_paths - {str(f.get("path")) for f in unique_failures if f.get("path")}),
|
|
617
|
+
"reasoning_summary": "Semantic verification used visible contract, produced artifacts, and actual_outputs refs only.",
|
|
618
|
+
"structural_contract_verification": structural,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def build_repair_plan(verification: dict[str, Any], *, repair_id: str | None = None) -> dict[str, Any]:
|
|
623
|
+
failures = verification.get("failures") or []
|
|
624
|
+
semantic_failures = verification.get("scorer_critical_failures") or []
|
|
625
|
+
all_failures = list(failures) + list(semantic_failures)
|
|
626
|
+
repair_allowed = verification.get("repair_allowed")
|
|
627
|
+
if repair_allowed is None:
|
|
628
|
+
repair_allowed = bool(all_failures)
|
|
629
|
+
files_to_modify = sorted({str(f.get("path")) for f in all_failures if f.get("path")})
|
|
630
|
+
do_not_modify = sorted(set(verification.get("do_not_modify") or []))
|
|
631
|
+
risk = "high" if len(files_to_modify) > 3 or verification.get("semantic_verification_status") == "uncertain" else "medium" if len(files_to_modify) > 1 else "low"
|
|
632
|
+
return {
|
|
633
|
+
"repair_id": repair_id or "contract_repair_001",
|
|
634
|
+
"repair_required": bool(all_failures),
|
|
635
|
+
"repair_allowed": bool(repair_allowed),
|
|
636
|
+
"failed_items": all_failures,
|
|
637
|
+
"issues_to_fix": all_failures,
|
|
638
|
+
"files_to_modify": files_to_modify,
|
|
639
|
+
"files_to_preserve": do_not_modify,
|
|
640
|
+
"specific_changes": [failure.get("repair_hint") for failure in all_failures if failure.get("repair_hint")],
|
|
641
|
+
"verification_after_repair": ["run structural contract verifier", "run semantic verifier", "run local grader if available"],
|
|
642
|
+
"risk_of_regression": risk,
|
|
643
|
+
"broad_rewrite_allowed": False,
|
|
644
|
+
"preserve_passing_items": True,
|
|
645
|
+
"repair_scope": "target_failed_contract_items_only",
|
|
646
|
+
"repair_steps": [
|
|
647
|
+
"Read only the listed contract failures and identify the affected files.",
|
|
648
|
+
"Preserve files that already passed contract checks.",
|
|
649
|
+
"Regenerate or patch each missing or malformed required output from current visible inputs.",
|
|
650
|
+
"Reopen CSV/JSON/TSV files and check required columns or keys.",
|
|
651
|
+
"Update actual_outputs.json so it lists generated files exactly.",
|
|
652
|
+
"Run the contract verifier again before finishing.",
|
|
653
|
+
] if all_failures and repair_allowed else [],
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def build_repair_result(
|
|
658
|
+
repair_plan: dict[str, Any],
|
|
659
|
+
*,
|
|
660
|
+
before_verification: dict[str, Any] | None = None,
|
|
661
|
+
after_verification: dict[str, Any] | None = None,
|
|
662
|
+
before_score: float | None = None,
|
|
663
|
+
after_score: float | None = None,
|
|
664
|
+
files_modified: list[str] | None = None,
|
|
665
|
+
reverted: bool = False,
|
|
666
|
+
notes: str = "",
|
|
667
|
+
) -> dict[str, Any]:
|
|
668
|
+
before_status = (before_verification or {}).get("semantic_verification_status") or (before_verification or {}).get("contract_verification_passed")
|
|
669
|
+
after_status = (after_verification or {}).get("semantic_verification_status") or (after_verification or {}).get("contract_verification_passed")
|
|
670
|
+
regression = False
|
|
671
|
+
if before_score is not None and after_score is not None and after_score < before_score:
|
|
672
|
+
regression = True
|
|
673
|
+
if before_status in {"pass", True} and after_status not in {"pass", True}:
|
|
674
|
+
regression = True
|
|
675
|
+
return {
|
|
676
|
+
"repair_id": repair_plan.get("repair_id") or "contract_repair_001",
|
|
677
|
+
"files_modified": files_modified or [],
|
|
678
|
+
"files_preserved": repair_plan.get("files_to_preserve") or [],
|
|
679
|
+
"before_score": before_score,
|
|
680
|
+
"after_score": after_score,
|
|
681
|
+
"before_contract_status": before_status,
|
|
682
|
+
"after_contract_status": after_status,
|
|
683
|
+
"regression_detected": regression,
|
|
684
|
+
"reverted": bool(reverted),
|
|
685
|
+
"notes": notes,
|
|
686
|
+
}
|