modelwright 0.1.0a7__tar.gz → 0.1.0a8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {modelwright-0.1.0a7/src/modelwright.egg-info → modelwright-0.1.0a8}/PKG-INFO +2 -2
  2. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/README.md +1 -1
  3. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/pyproject.toml +1 -1
  4. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/__init__.py +14 -2
  5. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/cli.py +106 -0
  6. modelwright-0.1.0a8/src/modelwright/evidence.py +433 -0
  7. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/freshforge.py +256 -5
  8. {modelwright-0.1.0a7 → modelwright-0.1.0a8/src/modelwright.egg-info}/PKG-INFO +2 -2
  9. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright.egg-info/SOURCES.txt +2 -0
  10. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_cli.py +150 -0
  11. modelwright-0.1.0a8/tests/test_evidence.py +189 -0
  12. modelwright-0.1.0a8/tests/test_freshforge_integration.py +586 -0
  13. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_import.py +1 -1
  14. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_public_api.py +5 -0
  15. modelwright-0.1.0a7/tests/test_freshforge_integration.py +0 -341
  16. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/LICENSE +0 -0
  17. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/MANIFEST.in +0 -0
  18. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/README.md +0 -0
  19. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/fable_2020/README.md +0 -0
  20. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/fable_2020/generated_fable_2020_model.py.xz +0 -0
  21. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/fable_2020/notebook_interface.py +0 -0
  22. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/fable_2020/scenario_output_manifest.example.json +0 -0
  23. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/notebooks/README.md +0 -0
  24. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/notebooks/fable-2020-notebook-interface.ipynb +0 -0
  25. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/notebooks/synthetic-notebook-interface.ipynb +0 -0
  26. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/examples/synthetic/notebook_interface.py +0 -0
  27. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/setup.cfg +0 -0
  28. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/conversion.py +0 -0
  29. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/evaluation.py +0 -0
  30. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/execution.py +0 -0
  31. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/extraction.py +0 -0
  32. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/formulas.py +0 -0
  33. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/formulas_oracle.py +0 -0
  34. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/generation.py +0 -0
  35. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/graph.py +0 -0
  36. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/notebooks.py +0 -0
  37. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/oracle_validation.py +0 -0
  38. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/oracles.py +0 -0
  39. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/references.py +0 -0
  40. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/validation.py +0 -0
  41. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright/wrappers.py +0 -0
  42. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright.egg-info/dependency_links.txt +0 -0
  43. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright.egg-info/entry_points.txt +0 -0
  44. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright.egg-info/requires.txt +0 -0
  45. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/src/modelwright.egg-info/top_level.txt +0 -0
  46. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_conversion_plan.py +0 -0
  47. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_dependency_graph.py +0 -0
  48. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_evaluation_orchestration.py +0 -0
  49. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_examples.py +0 -0
  50. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_extraction_records.py +0 -0
  51. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_fable_wrapper_benchmark.py +0 -0
  52. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_formula_expressions.py +0 -0
  53. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_formula_translation.py +0 -0
  54. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_formulas_oracle.py +0 -0
  55. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_generated_execution.py +0 -0
  56. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_generation_contract.py +0 -0
  57. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_materialize_fable_benchmarks.py +0 -0
  58. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_notebooks.py +0 -0
  59. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_openpyxl_extraction.py +0 -0
  60. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_oracle_backed_validation.py +0 -0
  61. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_oracle_interface.py +0 -0
  62. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_python_generation.py +0 -0
  63. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_references.py +0 -0
  64. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_scalar_comparison.py +0 -0
  65. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_supported_semantics_fixture.py +0 -0
  66. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_synthetic_fixture.py +0 -0
  67. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_validation.py +0 -0
  68. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_validation_regression.py +0 -0
  69. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_validation_report_builder.py +0 -0
  70. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_validation_scenario.py +0 -0
  71. {modelwright-0.1.0a7 → modelwright-0.1.0a8}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelwright
3
- Version: 0.1.0a7
3
+ Version: 0.1.0a8
4
4
  Summary: Tools for converting spreadsheet workbooks into transparent Python models.
5
5
  Author: UBC FRESH Lab
6
6
  License-Expression: MIT
@@ -139,7 +139,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
139
139
  scripts/bootstrap_dev_env.sh --benchmarks
140
140
  ```
141
141
 
142
- `modelwright` is pre-release. The current alpha line is `0.1.0a7`; alpha releases must not be described as full-workbook conversion guarantees.
142
+ `modelwright` is pre-release. The current alpha line is `0.1.0a8`; alpha releases must not be described as full-workbook conversion guarantees.
143
143
 
144
144
  Check release artifacts locally:
145
145
 
@@ -84,7 +84,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
84
84
  scripts/bootstrap_dev_env.sh --benchmarks
85
85
  ```
86
86
 
87
- `modelwright` is pre-release. The current alpha line is `0.1.0a7`; alpha releases must not be described as full-workbook conversion guarantees.
87
+ `modelwright` is pre-release. The current alpha line is `0.1.0a8`; alpha releases must not be described as full-workbook conversion guarantees.
88
88
 
89
89
  Check release artifacts locally:
90
90
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "modelwright"
7
- version = "0.1.0a7"
7
+ version = "0.1.0a8"
8
8
  description = "Tools for converting spreadsheet workbooks into transparent Python models."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,6 +19,13 @@ from modelwright.evaluation import (
19
19
  ValidationEvaluationResult,
20
20
  evaluate_generated_model,
21
21
  )
22
+ from modelwright.evidence import (
23
+ ValidationEvidencePaths,
24
+ ValidationEvidenceSummary,
25
+ extract_validation_evidence,
26
+ validation_evidence_paths,
27
+ write_validation_evidence,
28
+ )
22
29
  from modelwright.conversion import (
23
30
  ConversionPlan,
24
31
  ConversionSource,
@@ -104,7 +111,7 @@ from modelwright.wrappers import (
104
111
  table,
105
112
  )
106
113
 
107
- __version__ = "0.1.0a7"
114
+ __version__ = "0.1.0a8"
108
115
 
109
116
  __all__ = [
110
117
  "CellRecord",
@@ -152,8 +159,10 @@ __all__ = [
152
159
  "TableRef",
153
160
  "TableRecord",
154
161
  "TableView",
155
- "ValidationReport",
156
162
  "ValidationEvaluationResult",
163
+ "ValidationEvidencePaths",
164
+ "ValidationEvidenceSummary",
165
+ "ValidationReport",
157
166
  "ValidationScenario",
158
167
  "ValidationSummary",
159
168
  "WorkbookOracle",
@@ -173,6 +182,7 @@ __all__ = [
173
182
  "compare_scenarios_frame",
174
183
  "execute_generated_model",
175
184
  "evaluate_generated_model",
185
+ "extract_validation_evidence",
176
186
  "extract_workbook",
177
187
  "generate_python_module",
178
188
  "infer_generated_module_contract",
@@ -187,4 +197,6 @@ __all__ = [
187
197
  "table",
188
198
  "table_frame",
189
199
  "translate_formula_cell",
200
+ "validation_evidence_paths",
201
+ "write_validation_evidence",
190
202
  ]
@@ -12,6 +12,11 @@ from typer.main import get_command
12
12
 
13
13
  from modelwright.conversion import BenchmarkRole, build_conversion_plan
14
14
  from modelwright.evaluation import evaluate_generated_model
15
+ from modelwright.evidence import (
16
+ extract_validation_evidence,
17
+ validation_evidence_paths,
18
+ write_validation_evidence,
19
+ )
15
20
  from modelwright.execution import execute_generated_model
16
21
  from modelwright.extraction import WorkbookRecord, extract_workbook
17
22
  from modelwright.formulas import FormulaExpression, build_formula_reference_index, translate_formula_cell
@@ -319,6 +324,78 @@ def validation_evaluate(
319
324
  )
320
325
 
321
326
 
327
+ @validation_app.command("evidence")
328
+ def validation_evidence(
329
+ evidence_id: str = typer.Option(
330
+ "generated-model",
331
+ "--evidence-id",
332
+ help="Stable identifier for this compact evidence package.",
333
+ ),
334
+ artifact_dir: Path | None = typer.Option(
335
+ None,
336
+ "--artifact-dir",
337
+ help="Directory containing generated-model workflow artifacts.",
338
+ ),
339
+ output_dir: Path | None = typer.Option(
340
+ None,
341
+ "--output-dir",
342
+ help="Directory where compact summary.json and summary.md should be written.",
343
+ ),
344
+ scenario: Path | None = typer.Option(
345
+ None,
346
+ "--scenario",
347
+ exists=True,
348
+ dir_okay=False,
349
+ readable=True,
350
+ help="Optional validation scenario JSON path. Defaults to artifact-dir/validation-scenario.json.",
351
+ ),
352
+ require_artifacts: bool = typer.Option(
353
+ False,
354
+ "--require-artifacts",
355
+ help="Fail when any expected artifact is missing instead of writing skipped evidence.",
356
+ ),
357
+ json_output: bool = typer.Option(
358
+ False,
359
+ "--json",
360
+ help="Emit command result as JSON.",
361
+ ),
362
+ ) -> None:
363
+ """Package compact validation evidence from existing workflow artifacts."""
364
+
365
+ try:
366
+ payload = _validation_evidence_payload(
367
+ evidence_id=evidence_id,
368
+ artifact_dir=artifact_dir,
369
+ output_dir=output_dir,
370
+ scenario=scenario,
371
+ require_artifacts=require_artifacts,
372
+ )
373
+ except FileNotFoundError as error:
374
+ error_payload: dict[str, JsonValue] = {"ok": False, "error": str(error)}
375
+ if json_output:
376
+ _emit_json(error_payload)
377
+ raise typer.Exit(1) from error
378
+ raise typer.BadParameter(str(error)) from error
379
+
380
+ if json_output:
381
+ _emit_json(payload)
382
+ return
383
+
384
+ summary = cast(dict[str, JsonValue], payload["summary"])
385
+ comparison = cast(dict[str, JsonValue], summary["comparison"])
386
+ typer.echo("Modelwright validation evidence")
387
+ typer.echo(f"Evidence status: {summary['evidence_status']}")
388
+ typer.echo(f"Equivalence status: {summary['equivalence_status']}")
389
+ typer.echo(f"Comparable outputs: {comparison.get('comparable_output_count')}")
390
+ typer.echo(f"Matches: {comparison.get('match_count')}")
391
+ typer.echo(f"Mismatches: {comparison.get('mismatch_count')}")
392
+ typer.echo(f"Summary JSON: {payload['summary_json_path']}")
393
+ typer.echo(f"Summary Markdown: {payload['summary_markdown_path']}")
394
+ missing = cast(list[str], summary.get("missing_artifacts", []))
395
+ if missing:
396
+ typer.echo(f"Missing artifacts: {len(missing)}")
397
+
398
+
322
399
  @conversion_app.command("plan")
323
400
  def conversion_plan(
324
401
  workbook: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Source workbook path."),
@@ -518,6 +595,35 @@ def _evaluate_payload(
518
595
  return result.to_dict()
519
596
 
520
597
 
598
+ def _validation_evidence_payload(
599
+ *,
600
+ evidence_id: str,
601
+ artifact_dir: Path | None,
602
+ output_dir: Path | None,
603
+ scenario: Path | None,
604
+ require_artifacts: bool,
605
+ ) -> dict[str, JsonValue]:
606
+ paths = validation_evidence_paths(
607
+ evidence_id=evidence_id,
608
+ artifact_dir=artifact_dir,
609
+ output_dir=output_dir,
610
+ validation_scenario_path=scenario,
611
+ )
612
+ summary = extract_validation_evidence(paths, require_artifacts=require_artifacts)
613
+ written = write_validation_evidence(summary, paths)
614
+ return {
615
+ "ok": True,
616
+ "evidence_id": summary.evidence_id,
617
+ "evidence_status": summary.evidence_status,
618
+ "equivalence_status": summary.equivalence_status,
619
+ "summary_json_path": written["summary_json_path"],
620
+ "summary_markdown_path": written["summary_markdown_path"],
621
+ "missing_artifacts": list(summary.missing_artifacts),
622
+ "comparison": summary.comparison,
623
+ "summary": summary.to_dict(),
624
+ }
625
+
626
+
521
627
  def _conversion_plan_payload(
522
628
  *,
523
629
  workbook: Path,
@@ -0,0 +1,433 @@
1
+ """Compact validation-evidence summaries for generated-model workflows.
2
+
3
+ This module summarizes artifacts that were already produced by Modelwright
4
+ generation, execution, and validation steps. It deliberately does not rerun
5
+ those steps, and it does not copy raw generated source, raw generated output
6
+ values, workbook contents, or full comparison rows into the shareable summary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ JsonValue = str | int | float | bool | None | list[Any] | dict[str, Any]
17
+ EvidenceStatus = Literal["skipped", "incomplete", "complete"]
18
+ EquivalenceStatus = Literal["pass", "fail", "incomplete"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ValidationEvidencePaths:
23
+ """Filesystem contract for compact validation-evidence extraction."""
24
+
25
+ evidence_id: str
26
+ artifact_dir: Path
27
+ output_dir: Path
28
+ inference_result_path: Path
29
+ generation_result_path: Path
30
+ generated_values_path: Path
31
+ validation_scenario_path: Path
32
+ evaluation_report_path: Path
33
+ summary_json_path: Path
34
+ summary_markdown_path: Path
35
+
36
+ @property
37
+ def required_artifact_paths(self) -> tuple[Path, ...]:
38
+ """Return artifacts used as evidence inputs."""
39
+
40
+ return (
41
+ self.inference_result_path,
42
+ self.generation_result_path,
43
+ self.generated_values_path,
44
+ self.validation_scenario_path,
45
+ self.evaluation_report_path,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ValidationEvidenceSummary:
51
+ """Sanitized validation-evidence summary for publication or CI artifacts."""
52
+
53
+ evidence_id: str
54
+ evidence_status: EvidenceStatus
55
+ equivalence_status: EquivalenceStatus
56
+ missing_artifacts: tuple[str, ...] = field(default_factory=tuple)
57
+ artifacts: dict[str, str] = field(default_factory=dict)
58
+ stages: dict[str, JsonValue] = field(default_factory=dict)
59
+ comparison: dict[str, JsonValue] = field(default_factory=dict)
60
+ notes: tuple[str, ...] = field(default_factory=tuple)
61
+
62
+ def to_dict(self) -> dict[str, JsonValue]:
63
+ """Serialize the compact evidence summary."""
64
+
65
+ return {
66
+ "evidence_id": self.evidence_id,
67
+ "evidence_status": self.evidence_status,
68
+ "equivalence_status": self.equivalence_status,
69
+ "missing_artifacts": list(self.missing_artifacts),
70
+ "artifacts": self.artifacts,
71
+ "stages": self.stages,
72
+ "comparison": self.comparison,
73
+ "notes": list(self.notes),
74
+ }
75
+
76
+
77
+ def validation_evidence_paths(
78
+ *,
79
+ evidence_id: str = "generated-model",
80
+ artifact_dir: str | Path | None = None,
81
+ output_dir: str | Path | None = None,
82
+ validation_scenario_path: str | Path | None = None,
83
+ ) -> ValidationEvidencePaths:
84
+ """Build the generic path contract for validation-evidence extraction."""
85
+
86
+ artifact_root = Path(artifact_dir) if artifact_dir is not None else Path("tmp/generated-model")
87
+ output_root = Path(output_dir) if output_dir is not None else Path("tmp/validation-evidence") / evidence_id
88
+ scenario_path = (
89
+ Path(validation_scenario_path) if validation_scenario_path is not None else artifact_root / "validation-scenario.json"
90
+ )
91
+ return ValidationEvidencePaths(
92
+ evidence_id=evidence_id,
93
+ artifact_dir=artifact_root,
94
+ output_dir=output_root,
95
+ inference_result_path=artifact_root / "inference-result.json",
96
+ generation_result_path=artifact_root / "generation-result.json",
97
+ generated_values_path=artifact_root / "generated-values.json",
98
+ validation_scenario_path=scenario_path,
99
+ evaluation_report_path=artifact_root / "evaluation-report.json",
100
+ summary_json_path=output_root / "summary.json",
101
+ summary_markdown_path=output_root / "summary.md",
102
+ )
103
+
104
+
105
+ def extract_validation_evidence(
106
+ paths: ValidationEvidencePaths | None = None,
107
+ *,
108
+ evidence_id: str = "generated-model",
109
+ artifact_dir: str | Path | None = None,
110
+ output_dir: str | Path | None = None,
111
+ validation_scenario_path: str | Path | None = None,
112
+ require_artifacts: bool = False,
113
+ ) -> ValidationEvidenceSummary:
114
+ """Extract compact evidence from existing generated-model workflow artifacts."""
115
+
116
+ evidence_paths = paths or validation_evidence_paths(
117
+ evidence_id=evidence_id,
118
+ artifact_dir=artifact_dir,
119
+ output_dir=output_dir,
120
+ validation_scenario_path=validation_scenario_path,
121
+ )
122
+ missing = tuple(str(path) for path in evidence_paths.required_artifact_paths if not path.exists())
123
+ if missing and require_artifacts:
124
+ missing_list = ", ".join(missing)
125
+ raise FileNotFoundError(f"missing validation evidence artifact(s): {missing_list}")
126
+
127
+ artifacts = _artifact_summary(evidence_paths)
128
+ if missing:
129
+ return ValidationEvidenceSummary(
130
+ evidence_id=evidence_paths.evidence_id,
131
+ evidence_status="skipped",
132
+ equivalence_status="incomplete",
133
+ missing_artifacts=missing,
134
+ artifacts=artifacts,
135
+ notes=("Validation artifacts are missing; evidence extraction was skipped.",),
136
+ )
137
+
138
+ inference = _load_json_object(evidence_paths.inference_result_path)
139
+ generation = _load_json_object(evidence_paths.generation_result_path)
140
+ generated_values = _load_json_object(evidence_paths.generated_values_path)
141
+ scenario = _load_json_object(evidence_paths.validation_scenario_path)
142
+ evaluation = _load_json_object(evidence_paths.evaluation_report_path)
143
+
144
+ stages: dict[str, JsonValue] = {
145
+ "inference": _inference_stage(inference),
146
+ "generation": _generation_stage(generation),
147
+ "generated_values": _generated_values_stage(generated_values),
148
+ "validation_scenario": _validation_scenario_stage(scenario),
149
+ "evaluation": _evaluation_stage(evaluation),
150
+ }
151
+ comparison = _comparison_summary(evaluation)
152
+ equivalence_status = _equivalence_status(comparison)
153
+ evidence_status: EvidenceStatus = "complete" if equivalence_status in {"pass", "fail"} else "incomplete"
154
+ notes = _summary_notes(missing=(), stages=stages, comparison=comparison)
155
+ return ValidationEvidenceSummary(
156
+ evidence_id=evidence_paths.evidence_id,
157
+ evidence_status=evidence_status,
158
+ equivalence_status=equivalence_status,
159
+ artifacts=artifacts,
160
+ stages=stages,
161
+ comparison=comparison,
162
+ notes=notes,
163
+ )
164
+
165
+
166
+ def write_validation_evidence(
167
+ summary: ValidationEvidenceSummary,
168
+ paths: ValidationEvidencePaths | None = None,
169
+ *,
170
+ output_dir: str | Path | None = None,
171
+ ) -> dict[str, JsonValue]:
172
+ """Write ``summary.json`` and ``summary.md`` for compact evidence."""
173
+
174
+ if paths is not None:
175
+ json_path = paths.summary_json_path
176
+ markdown_path = paths.summary_markdown_path
177
+ else:
178
+ output_root = Path(output_dir) if output_dir is not None else Path("tmp/validation-evidence") / summary.evidence_id
179
+ json_path = output_root / "summary.json"
180
+ markdown_path = output_root / "summary.md"
181
+
182
+ json_path.parent.mkdir(parents=True, exist_ok=True)
183
+ payload = summary.to_dict()
184
+ json_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
185
+ markdown_path.write_text(_summary_markdown(summary), encoding="utf-8")
186
+ return {
187
+ "summary": payload,
188
+ "summary_json_path": str(json_path),
189
+ "summary_markdown_path": str(markdown_path),
190
+ }
191
+
192
+
193
+ def _artifact_summary(paths: ValidationEvidencePaths) -> dict[str, str]:
194
+ return {
195
+ "artifact_dir": str(paths.artifact_dir),
196
+ "inference_result": str(paths.inference_result_path),
197
+ "generation_result": str(paths.generation_result_path),
198
+ "generated_values": str(paths.generated_values_path),
199
+ "validation_scenario": str(paths.validation_scenario_path),
200
+ "evaluation_report": str(paths.evaluation_report_path),
201
+ "summary_json": str(paths.summary_json_path),
202
+ "summary_markdown": str(paths.summary_markdown_path),
203
+ }
204
+
205
+
206
+ def _inference_stage(data: dict[str, JsonValue]) -> dict[str, JsonValue]:
207
+ contract = _object(data.get("contract"))
208
+ return {
209
+ "available": True,
210
+ "inferred": bool(data.get("inferred", False)),
211
+ "input_ref_count": _length(contract.get("input_refs")),
212
+ "output_ref_count": _length(contract.get("output_refs")),
213
+ "symbol_count": _length(contract.get("symbols")),
214
+ "expression_count": _length(data.get("expressions")),
215
+ "constant_count": _length(data.get("constants")),
216
+ "diagnostic_count": _diagnostic_count(data),
217
+ "error_diagnostic_count": _diagnostic_count(data, severity="error"),
218
+ }
219
+
220
+
221
+ def _generation_stage(data: dict[str, JsonValue]) -> dict[str, JsonValue]:
222
+ contract = _object(data.get("contract"))
223
+ source_code = data.get("source_code")
224
+ source_size = len(source_code.encode("utf-8")) if isinstance(source_code, str) else None
225
+ source_lines = source_code.count("\n") + 1 if isinstance(source_code, str) and source_code else None
226
+ return {
227
+ "available": True,
228
+ "generated": bool(data.get("generated", False)),
229
+ "input_ref_count": _length(contract.get("input_refs")),
230
+ "output_ref_count": _length(contract.get("output_refs")),
231
+ "symbol_count": _length(contract.get("symbols")),
232
+ "source_size_bytes": source_size,
233
+ "source_line_count": source_lines,
234
+ "diagnostic_count": _diagnostic_count(data),
235
+ "error_diagnostic_count": _diagnostic_count(data, severity="error"),
236
+ }
237
+
238
+
239
+ def _generated_values_stage(data: dict[str, JsonValue]) -> dict[str, JsonValue]:
240
+ contract = _object(data.get("contract"))
241
+ output_values = _object(data.get("output_values"))
242
+ return {
243
+ "available": True,
244
+ "executed": bool(data.get("executed", False)),
245
+ "declared_output_count": _length(contract.get("output_refs")),
246
+ "generated_output_count": len(output_values),
247
+ "diagnostic_count": _diagnostic_count(data),
248
+ "error_diagnostic_count": _diagnostic_count(data, severity="error"),
249
+ }
250
+
251
+
252
+ def _validation_scenario_stage(data: dict[str, JsonValue]) -> dict[str, JsonValue]:
253
+ outputs = _sequence(data.get("outputs"))
254
+ output_kinds = sorted(
255
+ {item.get("kind") for item in outputs if isinstance(item, dict) and isinstance(item.get("kind"), str)}
256
+ )
257
+ return {
258
+ "available": True,
259
+ "scenario_id": data.get("scenario_id"),
260
+ "input_count": _length(data.get("inputs")),
261
+ "output_count": len(outputs),
262
+ "output_kinds": output_kinds,
263
+ }
264
+
265
+
266
+ def _evaluation_stage(data: dict[str, JsonValue]) -> dict[str, JsonValue]:
267
+ generated_execution = _object(data.get("generated_execution"))
268
+ cached_report = _object(data.get("cached_validation_report"))
269
+ oracle_report = _object(data.get("oracle_validation_report"))
270
+ return {
271
+ "available": True,
272
+ "scenario_id": data.get("scenario_id"),
273
+ "generated_executed": bool(generated_execution.get("executed", False)),
274
+ "generated_output_count": _length(generated_execution.get("output_values")),
275
+ "has_cached_validation_report": bool(cached_report),
276
+ "has_oracle_validation_report": bool(oracle_report),
277
+ "cached_validation_status": cached_report.get("status"),
278
+ "oracle_validation_status": oracle_report.get("status"),
279
+ "diagnostic_count": _diagnostic_count(data),
280
+ "error_diagnostic_count": _diagnostic_count(data, severity="error"),
281
+ }
282
+
283
+
284
+ def _comparison_summary(evaluation: dict[str, JsonValue]) -> dict[str, JsonValue]:
285
+ reports = [
286
+ _object(evaluation.get("cached_validation_report")),
287
+ _object(evaluation.get("oracle_validation_report")),
288
+ ]
289
+ report = next((item for item in reports if item), None)
290
+ if report is None:
291
+ return {
292
+ "comparison_counts_available": False,
293
+ "comparable_output_count": None,
294
+ "match_count": None,
295
+ "mismatch_count": None,
296
+ "validation_report_status": None,
297
+ "validation_backend": None,
298
+ }
299
+
300
+ comparisons = _sequence(report.get("comparisons"))
301
+ mismatches = _sequence(report.get("mismatches"))
302
+ comparable_count = _find_count(report, _COMPARABLE_KEYS)
303
+ match_count = _find_count(report, _MATCH_KEYS)
304
+ mismatch_count = _find_count(report, _MISMATCH_KEYS)
305
+ if comparable_count is None and comparisons:
306
+ comparable_count = len(comparisons)
307
+ if match_count is None and comparisons:
308
+ match_count = sum(1 for item in comparisons if isinstance(item, dict) and item.get("matches") is True)
309
+ if mismatch_count is None and (comparisons or mismatches):
310
+ mismatch_count = len(mismatches) if mismatches else sum(
311
+ 1 for item in comparisons if isinstance(item, dict) and item.get("matches") is False
312
+ )
313
+
314
+ counts_available = comparable_count is not None and match_count is not None and mismatch_count is not None
315
+ return {
316
+ "comparison_counts_available": counts_available,
317
+ "comparable_output_count": comparable_count,
318
+ "match_count": match_count,
319
+ "mismatch_count": mismatch_count,
320
+ "validation_report_status": report.get("status"),
321
+ "validation_backend": report.get("oracle_backend"),
322
+ "validation_diagnostic_count": _diagnostic_count(report),
323
+ "validation_error_diagnostic_count": _diagnostic_count(report, severity="error"),
324
+ }
325
+
326
+
327
+ def _equivalence_status(comparison: dict[str, JsonValue]) -> EquivalenceStatus:
328
+ comparable = comparison.get("comparable_output_count")
329
+ matches = comparison.get("match_count")
330
+ mismatches = comparison.get("mismatch_count")
331
+ if not all(isinstance(value, int) and not isinstance(value, bool) for value in (comparable, matches, mismatches)):
332
+ return "incomplete"
333
+ if comparable == matches and mismatches == 0:
334
+ return "pass"
335
+ return "fail"
336
+
337
+
338
+ def _summary_notes(
339
+ *,
340
+ missing: tuple[str, ...],
341
+ stages: dict[str, JsonValue],
342
+ comparison: dict[str, JsonValue],
343
+ ) -> tuple[str, ...]:
344
+ notes: list[str] = []
345
+ if missing:
346
+ notes.append("Validation artifacts are missing; evidence extraction was skipped.")
347
+ if comparison.get("comparison_counts_available") is not True:
348
+ notes.append("Explicit comparable-output, match, and mismatch counts were not found.")
349
+ diagnostics = sum(
350
+ int(stage.get("diagnostic_count", 0))
351
+ for stage in stages.values()
352
+ if isinstance(stage, dict) and isinstance(stage.get("diagnostic_count"), int)
353
+ )
354
+ if diagnostics:
355
+ notes.append(f"Stage diagnostics were reported: {diagnostics}.")
356
+ return tuple(notes)
357
+
358
+
359
+ def _summary_markdown(summary: ValidationEvidenceSummary) -> str:
360
+ comparison = summary.comparison
361
+ lines = [
362
+ f"# Modelwright Validation Evidence: {summary.evidence_id}",
363
+ "",
364
+ f"- Evidence status: `{summary.evidence_status}`",
365
+ f"- Equivalence status: `{summary.equivalence_status}`",
366
+ f"- Comparable outputs: `{comparison.get('comparable_output_count')}`",
367
+ f"- Matches: `{comparison.get('match_count')}`",
368
+ f"- Mismatches: `{comparison.get('mismatch_count')}`",
369
+ "",
370
+ "## Artifacts",
371
+ "",
372
+ ]
373
+ for key, path in summary.artifacts.items():
374
+ lines.append(f"- `{key}`: `{path}`")
375
+ if summary.missing_artifacts:
376
+ lines.extend(["", "## Missing Artifacts", ""])
377
+ lines.extend(f"- `{path}`" for path in summary.missing_artifacts)
378
+ if summary.notes:
379
+ lines.extend(["", "## Notes", ""])
380
+ lines.extend(f"- {note}" for note in summary.notes)
381
+ lines.append("")
382
+ return "\n".join(lines)
383
+
384
+
385
+ def _load_json_object(path: Path) -> dict[str, JsonValue]:
386
+ data = json.loads(path.read_text(encoding="utf-8"))
387
+ if not isinstance(data, dict):
388
+ raise ValueError(f"expected JSON object in {path}")
389
+ return data
390
+
391
+
392
+ def _object(value: JsonValue) -> dict[str, JsonValue]:
393
+ return value if isinstance(value, dict) else {}
394
+
395
+
396
+ def _sequence(value: JsonValue) -> list[JsonValue]:
397
+ return value if isinstance(value, list) else []
398
+
399
+
400
+ def _length(value: JsonValue) -> int | None:
401
+ if isinstance(value, dict | list):
402
+ return len(value)
403
+ return None
404
+
405
+
406
+ def _diagnostic_count(data: dict[str, JsonValue], *, severity: str | None = None) -> int:
407
+ diagnostics = _sequence(data.get("diagnostics"))
408
+ if severity is None:
409
+ return len(diagnostics)
410
+ return sum(1 for item in diagnostics if isinstance(item, dict) and item.get("severity") == severity)
411
+
412
+
413
+ _COMPARABLE_KEYS = ("comparable_output_count", "comparable_outputs", "total_comparable_outputs")
414
+ _MATCH_KEYS = ("match_count", "matches", "matched_outputs", "generated_output_matches")
415
+ _MISMATCH_KEYS = ("mismatch_count", "mismatches", "mismatched_outputs")
416
+
417
+
418
+ def _find_count(data: JsonValue, keys: tuple[str, ...]) -> int | None:
419
+ if isinstance(data, dict):
420
+ for key in keys:
421
+ value = data.get(key)
422
+ if isinstance(value, int) and not isinstance(value, bool):
423
+ return value
424
+ for value in data.values():
425
+ found = _find_count(value, keys)
426
+ if found is not None:
427
+ return found
428
+ elif isinstance(data, list):
429
+ for item in data:
430
+ found = _find_count(item, keys)
431
+ if found is not None:
432
+ return found
433
+ return None