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.
@@ -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
+ }