python-hwpx 3.6.0__py3-none-any.whl → 3.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. hwpx/__init__.py +12 -0
  2. hwpx/_document/persistence.py +196 -15
  3. hwpx/agent/_batch_verification.py +383 -0
  4. hwpx/agent/blueprint/replay.py +429 -259
  5. hwpx/agent/commands.py +269 -483
  6. hwpx/agent/form_plan.py +167 -92
  7. hwpx/agent/model.py +56 -0
  8. hwpx/authoring.py +206 -113
  9. hwpx/body_patch.py +16 -0
  10. hwpx/document.py +102 -8
  11. hwpx/equation/__init__.py +46 -0
  12. hwpx/equation/eqedit.py +317 -0
  13. hwpx/equation/mathml.py +69 -0
  14. hwpx/equation/render.py +83 -0
  15. hwpx/equation/tokens.py +256 -0
  16. hwpx/evalplan_fill.py +347 -173
  17. hwpx/fill_residue.py +73 -36
  18. hwpx/mutation_report.py +524 -0
  19. hwpx/opc/package.py +7 -0
  20. hwpx/oxml/table.py +216 -128
  21. hwpx/patch.py +24 -0
  22. hwpx/table_patch.py +16 -0
  23. hwpx/tools/__init__.py +8 -0
  24. hwpx/tools/document_viewer.py +182 -0
  25. hwpx/tools/fuzz/catalog.py +105 -84
  26. hwpx/tools/layout_preview.py +95 -16
  27. hwpx/tools/markdown_export.py +76 -54
  28. hwpx/tools/object_finder.py +100 -84
  29. hwpx/tools/toc_fidelity.py +87 -56
  30. hwpx/visual/fixture_corpus.py +106 -71
  31. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/METADATA +101 -11
  32. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/RECORD +37 -29
  33. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/NOTICE +13 -1
  34. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/WHEEL +0 -0
  35. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/entry_points.txt +0 -0
  36. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/licenses/LICENSE +0 -0
  37. {python_hwpx-3.6.0.dist-info → python_hwpx-3.8.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py CHANGED
@@ -75,6 +75,10 @@ from .tools.layout_preview import (
75
75
  PreviewPage,
76
76
  render_layout_preview,
77
77
  )
78
+ from .tools.document_viewer import (
79
+ DocumentViewer,
80
+ render_document_viewer,
81
+ )
78
82
  from .ingest import (
79
83
  ConversionAttempt,
80
84
  DocumentConverter,
@@ -85,6 +89,10 @@ from .ingest import (
85
89
  HwpxMarkdownConverter,
86
90
  UnsupportedDocumentFormat,
87
91
  )
92
+ from .mutation_report import (
93
+ MutationReport,
94
+ PreservationDowngradeError,
95
+ )
88
96
  from .patch import (
89
97
  BytePreservingPatchResult,
90
98
  ParagraphTextPatch,
@@ -146,6 +154,8 @@ __all__ = [
146
154
  "ParagraphInfo",
147
155
  "PackageValidationReport",
148
156
  "BytePreservingPatchResult",
157
+ "MutationReport",
158
+ "PreservationDowngradeError",
149
159
  "PlanValidationReport",
150
160
  "ParagraphTextPatch",
151
161
  "PatchApplied",
@@ -200,6 +210,8 @@ __all__ = [
200
210
  "validate_package",
201
211
  "paragraph_patch",
202
212
  "render_layout_preview",
213
+ "render_document_viewer",
214
+ "DocumentViewer",
203
215
  "register_template",
204
216
  "table_compute",
205
217
  ]
@@ -5,8 +5,18 @@ from __future__ import annotations
5
5
 
6
6
  import warnings
7
7
  from os import PathLike
8
- from typing import TYPE_CHECKING, Any, BinaryIO, Sequence
9
-
8
+ from typing import TYPE_CHECKING, Any, BinaryIO, Literal, Sequence
9
+
10
+ from ..mutation_report import (
11
+ Fallback,
12
+ Mode,
13
+ MutationReport,
14
+ PreservationDowngradeError,
15
+ PreservationMeasurement,
16
+ VerificationSummary,
17
+ VerificationValue,
18
+ measure_save,
19
+ )
10
20
  from ..opc.package import _UNCHECKED_SAVE_TOKEN
11
21
  from ..quality import QualityPolicy
12
22
  from ..quality.report import OpenSafetyReport
@@ -120,27 +130,178 @@ def _gate_and_write(
120
130
  return report
121
131
 
122
132
 
123
- def save_to_path(doc: "HwpxDocument", path: str | PathLike[str]) -> str | PathLike[str]:
124
- """Persist pending changes to *path* and return the same path."""
133
+ def _build_measured(
134
+ doc: "HwpxDocument", *, reset_dirty: bool
135
+ ) -> tuple[bytes, PreservationMeasurement]:
136
+ """Serialize + open-safety validate exactly like ``_to_bytes_raw`` and measure
137
+ the built archive's preservation against the pre-save part payloads.
138
+
139
+ The pre-save snapshot is captured *before* the ``serialize()`` updates are
140
+ applied so a per-save normalizer touching a part the editor never declared
141
+ dirty surfaces as an ``unexpected`` change (Safe Write Contract §2).
142
+ """
143
+
144
+ package = doc._package
145
+ # Measure against the OPEN-TIME baseline, not the current package state: a
146
+ # low-level ``set_part`` applied before this save already lives in
147
+ # ``_files``, and measuring against it would silently launder that change
148
+ # through a ``mode="patch"`` grade (Safe Write Contract §1, fail-closed).
149
+ source_members = dict(
150
+ getattr(package, "_opened_members", None) or package._files
151
+ )
152
+ source_infos = dict(
153
+ getattr(package, "_opened_zip_infos", None) or package._zip_infos
154
+ )
155
+ updates = doc._root.serialize()
156
+ predeclared = set(updates)
157
+ if updates:
158
+ for part_name, payload in updates.items():
159
+ package.set_part(part_name, payload)
160
+ result = package._save_to_bytes(verify_open_safety=True, mark_clean=False)
161
+ if not isinstance(result, bytes):
162
+ raise TypeError("package.save(None) must return bytes")
163
+ doc._run_open_safety_validation(result)
164
+ if reset_dirty:
165
+ _mark_save_clean(doc)
166
+ measurement = measure_save(source_members, source_infos, result, predeclared)
167
+ return result, measurement
168
+
169
+
170
+ def _resolve_grade(
171
+ mode: Mode,
172
+ fallback: Fallback,
173
+ measurement: PreservationMeasurement,
174
+ ) -> tuple[Literal["patch", "rebuild"], bool]:
175
+ """Return ``(actual_mode, fallback_used)``; raise before any write when a
176
+ requested ``patch`` grade is unmet and ``fallback="error"`` (§1)."""
177
+
178
+ if mode == "patch" and not measurement.patch_grade_ok:
179
+ if fallback == "error":
180
+ raise PreservationDowngradeError(
181
+ requested_mode=mode,
182
+ achieved_grade=measurement.achieved_grade,
183
+ offending_parts=measurement.offending_parts,
184
+ suggestion=(
185
+ "route these edits through a byte-preserving primitive "
186
+ "(hwpx.patch/table_patch/body_patch) or pass fallback='rebuild'."
187
+ ),
188
+ )
189
+ return "rebuild", True
190
+ if mode == "auto":
191
+ return measurement.achieved_grade, False
192
+ if mode == "patch":
193
+ return "patch", False
194
+ return "rebuild", False
195
+
196
+
197
+ def _verification_summary(report: "VisualCompleteReport") -> VerificationSummary:
198
+ """Reflect what the pipeline actually ran; never fabricate a pass (§2)."""
199
+
200
+ package: VerificationValue = "passed" if report.ok else "failed"
201
+ open_safety: VerificationValue = "passed" if report.open_safety.ok else "failed"
202
+ # Open-safety validation (which includes the reopen probe) already ran during
203
+ # serialize and raised on failure, so reaching here means reopen passed.
204
+ reopen: VerificationValue = "passed"
205
+ status = report.visual_complete_status
206
+ visual: VerificationValue = (
207
+ "passed"
208
+ if status == "verified"
209
+ else "failed"
210
+ if status == "failed"
211
+ else "not_performed"
212
+ )
213
+ return VerificationSummary(
214
+ package=package, open_safety=open_safety, reopen=reopen, visual=visual
215
+ )
216
+
217
+
218
+ def _compose_mutation_report(
219
+ *,
220
+ path: str | None,
221
+ requested_mode: Mode,
222
+ actual_mode: Literal["patch", "rebuild"],
223
+ fallback_used: bool,
224
+ measurement: PreservationMeasurement,
225
+ report: "VisualCompleteReport",
226
+ ) -> MutationReport:
227
+ return MutationReport(
228
+ requested_mode=requested_mode,
229
+ actual_mode=actual_mode,
230
+ fallback_used=fallback_used,
231
+ changed_parts=measurement.changed_parts,
232
+ preservation=measurement.preservation,
233
+ verification=_verification_summary(report),
234
+ path=path,
235
+ )
236
+
237
+
238
+ def save_to_path(
239
+ doc: "HwpxDocument",
240
+ path: str | PathLike[str],
241
+ *,
242
+ mode: Mode = "auto",
243
+ fallback: Fallback = "error",
244
+ return_report: bool = False,
245
+ ) -> str | PathLike[str] | MutationReport:
246
+ """Persist pending changes to *path*.
247
+
248
+ Returns *path* by default; ``return_report=True`` returns the
249
+ :class:`~hwpx.mutation_report.MutationReport` receipt instead. ``mode`` and
250
+ ``fallback`` are the Safe Write Contract grade controls — ``mode="patch"``
251
+ with ``fallback="error"`` raises :class:`PreservationDowngradeError` and
252
+ writes nothing when the save cannot keep every untouched part byte-identical.
253
+ """
125
254
 
126
255
  _run_pre_save_validation(doc)
127
- archive_bytes = doc._to_bytes_raw(reset_dirty=False)
128
- _gate_and_write(doc,
129
- archive_bytes, output_path=path, source_label="document.save_to_path"
256
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
257
+ actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
258
+ report = _gate_and_write(
259
+ doc, archive_bytes, output_path=path, source_label="document.save_to_path"
130
260
  )
131
261
  _mark_save_clean(doc)
262
+ if return_report:
263
+ return _compose_mutation_report(
264
+ path=str(path),
265
+ requested_mode=mode,
266
+ actual_mode=actual_mode,
267
+ fallback_used=fallback_used,
268
+ measurement=measurement,
269
+ report=report,
270
+ )
132
271
  return path
133
272
 
134
273
 
135
- def save_to_stream(doc: "HwpxDocument", stream: BinaryIO) -> BinaryIO:
136
- """Persist pending changes to *stream* and return the same stream."""
274
+ def save_to_stream(
275
+ doc: "HwpxDocument",
276
+ stream: BinaryIO,
277
+ *,
278
+ mode: Mode = "auto",
279
+ fallback: Fallback = "error",
280
+ return_report: bool = False,
281
+ ) -> BinaryIO | MutationReport:
282
+ """Persist pending changes to *stream*.
283
+
284
+ Returns *stream* by default; ``return_report=True`` returns the
285
+ :class:`~hwpx.mutation_report.MutationReport` receipt instead. See
286
+ :func:`save_to_path` for the ``mode``/``fallback`` grade semantics.
287
+ """
137
288
 
138
289
  _run_pre_save_validation(doc)
139
- archive_bytes = doc._to_bytes_raw(reset_dirty=False)
140
- _gate_and_write(doc,
141
- archive_bytes, output_stream=stream, source_label="document.save_to_stream"
290
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
291
+ actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
292
+ report = _gate_and_write(
293
+ doc, archive_bytes, output_stream=stream, source_label="document.save_to_stream"
142
294
  )
143
295
  _mark_save_clean(doc)
296
+ if return_report:
297
+ return _compose_mutation_report(
298
+ path=None,
299
+ requested_mode=mode,
300
+ actual_mode=actual_mode,
301
+ fallback_used=fallback_used,
302
+ measurement=measurement,
303
+ report=report,
304
+ )
144
305
  return stream
145
306
 
146
307
 
@@ -193,11 +354,26 @@ def save_report(
193
354
  return report
194
355
 
195
356
 
196
- def to_bytes(doc: "HwpxDocument") -> bytes:
197
- """Serialize pending changes and return the HWPX archive as bytes."""
357
+ def to_bytes(
358
+ doc: "HwpxDocument",
359
+ *,
360
+ mode: Mode = "auto",
361
+ fallback: Fallback = "error",
362
+ ) -> bytes:
363
+ """Serialize pending changes and return the HWPX archive as bytes.
364
+
365
+ ``mode``/``fallback`` enforce the Safe Write Contract grade the same way as
366
+ :func:`save_to_path`: ``mode="patch"`` with ``fallback="error"`` raises
367
+ :class:`PreservationDowngradeError` before returning when the archive is not
368
+ patch-grade. There is no ``return_report`` — the enforcement is via the typed
369
+ exception, so the byte return stays unchanged.
370
+ """
198
371
 
199
372
  _run_pre_save_validation(doc)
200
- return doc._to_bytes_raw()
373
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
374
+ _resolve_grade(mode, fallback, measurement)
375
+ _mark_save_clean(doc)
376
+ return archive_bytes
201
377
 
202
378
 
203
379
  def _to_bytes_raw(
@@ -239,6 +415,11 @@ def _to_bytes_for_validation(doc: "HwpxDocument") -> bytes:
239
415
  def _mark_save_clean(doc: "HwpxDocument") -> None:
240
416
  doc._root.reset_dirty()
241
417
  doc._package.version_info.mark_clean()
418
+ # The published archive is the new preservation baseline for any further
419
+ # save on this document instance.
420
+ package = doc._package
421
+ package._opened_members = dict(package._files)
422
+ package._opened_zip_infos = dict(package._zip_infos)
242
423
 
243
424
 
244
425
  def save(
@@ -0,0 +1,383 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Verification/orchestration primitives for :func:`hwpx.agent.commands.apply_document_commands`.
3
+
4
+ Split out of ``commands.py`` (S-088 P3, agent/commands.py:apply_document_commands
5
+ complexity decomposition) to keep that module under its enforced line-count
6
+ ratchet. Everything here is self-contained -- it depends only on
7
+ ``hwpx.agent.model``/``hwpx.agent.document`` and absolute ``hwpx`` imports, never
8
+ on ``commands.py`` itself, so ``commands.py`` can import from this module
9
+ without creating an import cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from collections.abc import Callable, Mapping, MutableMapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from hwpx.document import HwpxDocument
21
+ from hwpx.mutation_report import member_diff_bytes
22
+ from hwpx.quality import QualityPolicy, SavePipeline
23
+ from hwpx.tools.package_validator import validate_editor_open_safety
24
+
25
+ from .document import HwpxAgentDocument
26
+ from .model import AgentBatchResult, AgentContractError, AgentError
27
+
28
+ _EMPTY_REVISION = "sha256:" + hashlib.sha256(b"").hexdigest()
29
+
30
+ IdempotencyStore = MutableMapping[str, Any]
31
+ FaultInjector = Callable[[str, int | None], None]
32
+ DomainVerifier = Callable[[bytes, Mapping[str, Any]], Mapping[str, Any]]
33
+
34
+
35
+ def _revision(data: bytes) -> str:
36
+ return "sha256:" + hashlib.sha256(data).hexdigest()
37
+
38
+
39
+ def _error_from_exception(exc: BaseException, *, target: str | None = None) -> AgentError:
40
+ if isinstance(exc, AgentContractError):
41
+ code = exc.code
42
+ message = str(exc)
43
+ target = exc.target or target
44
+ elif isinstance(exc, (KeyError, IndexError, TypeError, ValueError)):
45
+ code = "invariant_violation"
46
+ message = str(exc) or type(exc).__name__
47
+ else:
48
+ code = "verification_failed"
49
+ message = f"{type(exc).__name__}: {exc}"
50
+ recoverability = "retryable" if code in {"stale_revision", "idempotency_conflict"} else "terminal"
51
+ if code in {"ambiguous_target", "volatile_target", "unsupported_content"}:
52
+ recoverability = "needs-review"
53
+ suggestions = {
54
+ "stale_revision": "Read the document again and retry with its current revision.",
55
+ "ambiguous_target": "Resolve a unique canonical path before mutation.",
56
+ "volatile_target": "Refresh the positional path from the current document revision.",
57
+ "unknown_property": "Use the node capability catalog to choose an editable property.",
58
+ "incompatible_parent": "Choose a compatible semantic parent and position.",
59
+ "verification_failed": "Inspect verificationReport and do not publish the candidate.",
60
+ }
61
+ return AgentError(
62
+ code=code,
63
+ message=message[:4096],
64
+ target=target,
65
+ recoverability=recoverability,
66
+ suggestion=suggestions.get(code),
67
+ )
68
+
69
+
70
+ def _failure_result(
71
+ *,
72
+ exc: BaseException,
73
+ batch: Mapping[str, Any] | None,
74
+ input_revision: str = _EMPTY_REVISION,
75
+ command_results: list[Mapping[str, Any]] | None = None,
76
+ verification: Mapping[str, Any] | None = None,
77
+ ) -> AgentBatchResult:
78
+ raw = batch or {}
79
+ output = raw.get("output") if isinstance(raw, Mapping) else None
80
+ output_filename = ""
81
+ if isinstance(output, Mapping):
82
+ output_filename = str(output.get("filename") or "")
83
+ return AgentBatchResult(
84
+ ok=False,
85
+ rolled_back=True,
86
+ dry_run=bool(raw.get("dryRun", False)),
87
+ input_revision=input_revision,
88
+ document_revision=input_revision,
89
+ output_filename=output_filename,
90
+ command_results=tuple(command_results or ()),
91
+ verification_report=dict(verification or {}),
92
+ error=_error_from_exception(exc),
93
+ )
94
+
95
+
96
+ def _quality_policy(value: str | Mapping[str, Any] | None) -> QualityPolicy:
97
+ if value is None or value == "transparent":
98
+ return QualityPolicy.transparent()
99
+ if value == "strict":
100
+ return QualityPolicy.strict()
101
+ if not isinstance(value, Mapping): # already contract-validated
102
+ raise AgentContractError("invalid_syntax", "quality is invalid", target="batch.quality")
103
+ mode = value.get("mode", "transparent")
104
+ policy = QualityPolicy.strict() if mode == "strict" else QualityPolicy.transparent()
105
+ mapping = {
106
+ "renderCheck": "render_check",
107
+ "xsdMode": "xsd_mode",
108
+ "overflowPolicy": "overflow_policy",
109
+ "layoutLint": "layout_lint",
110
+ "preserveUnmodifiedParts": "preserve_unmodified_parts",
111
+ "requireReferenceIntegrity": "require_reference_integrity",
112
+ }
113
+ changes = {mapping[name]: setting for name, setting in value.items() if name in mapping}
114
+ return policy.with_(**changes)
115
+
116
+
117
+ def _member_diff(before: bytes, after: bytes) -> dict[str, Any]:
118
+ # Shared with the Safe Write Contract's MutationReport spine: one uncompressed
119
+ # member comparison, one home for the diff shape (mutation_report.py).
120
+ return member_diff_bytes(before, after)
121
+
122
+
123
+ def _verify_header_story_candidates(
124
+ candidate_data: bytes,
125
+ candidate_revision: str,
126
+ expectations: Mapping[str, Mapping[str, str]],
127
+ ) -> dict[str, Any]:
128
+ receipts: list[dict[str, Any]] = []
129
+ if expectations:
130
+ with HwpxDocument.open(candidate_data) as reopened:
131
+ view = HwpxAgentDocument.from_document(
132
+ reopened, revision=candidate_revision
133
+ )
134
+ for expectation in expectations.values():
135
+ path = expectation["path"]
136
+ binding = view._resolve_header_story(path)
137
+ if (
138
+ binding.stable_id != expectation["stableId"]
139
+ or binding.page_type != expectation["pageType"]
140
+ or binding.text != expectation["text"]
141
+ ):
142
+ raise AgentContractError(
143
+ "verification_failed",
144
+ "reopened header story does not match the committed binding",
145
+ target=path,
146
+ )
147
+ receipts.append(
148
+ {
149
+ "commandId": expectation["commandId"],
150
+ "path": binding.path,
151
+ "stableId": binding.stable_id,
152
+ "pageType": binding.page_type,
153
+ "textMatched": True,
154
+ }
155
+ )
156
+ return {
157
+ "schemaVersion": "hwpx.agent-story-preservation/v1",
158
+ "ok": True,
159
+ "storyCount": len(receipts),
160
+ "stories": receipts,
161
+ }
162
+
163
+
164
+ def _request_hash(batch: Mapping[str, Any]) -> str:
165
+ payload = json.dumps(
166
+ batch,
167
+ ensure_ascii=False,
168
+ sort_keys=True,
169
+ separators=(",", ":"),
170
+ ).encode("utf-8")
171
+ return _revision(payload)
172
+
173
+
174
+ def _call_fault(injector: FaultInjector | None, stage: str, index: int | None = None) -> None:
175
+ if injector is not None:
176
+ injector(stage, index)
177
+
178
+
179
+ def _apply_commands_idempotency_lookup(
180
+ verification: dict[str, Any],
181
+ idempotency_store: IdempotencyStore | None,
182
+ *,
183
+ key: str | None,
184
+ request_hash: str,
185
+ ) -> AgentBatchResult | None:
186
+ verification["idempotency"] = {
187
+ "keyProvided": key is not None,
188
+ "requestHash": request_hash,
189
+ "replayed": False,
190
+ "store": "caller-owned" if idempotency_store is not None else "none",
191
+ }
192
+ if key is not None and idempotency_store is not None and key in idempotency_store:
193
+ cached = idempotency_store[key]
194
+ if not isinstance(cached, Mapping) or cached.get("requestHash") != request_hash:
195
+ raise AgentContractError(
196
+ "idempotency_conflict",
197
+ "idempotency key was used for a different request",
198
+ target="batch.idempotencyKey",
199
+ )
200
+ prior = cached.get("result")
201
+ if not isinstance(prior, AgentBatchResult):
202
+ raise AgentContractError(
203
+ "invariant_violation", "idempotency store contains an invalid result", target="batch.idempotencyKey"
204
+ )
205
+ replay_report = dict(prior.verification_report)
206
+ replay_idempotency = dict(replay_report.get("idempotency", {}))
207
+ replay_idempotency["replayed"] = True
208
+ replay_report["idempotency"] = replay_idempotency
209
+ return AgentBatchResult(
210
+ ok=prior.ok,
211
+ rolled_back=prior.rolled_back,
212
+ dry_run=prior.dry_run,
213
+ input_revision=prior.input_revision,
214
+ document_revision=prior.document_revision,
215
+ output_filename=prior.output_filename,
216
+ command_results=prior.command_results,
217
+ semantic_diff=prior.semantic_diff,
218
+ verification_report=replay_report,
219
+ error=prior.error,
220
+ )
221
+ return None
222
+
223
+
224
+ def _validate_apply_commands_input(
225
+ normalized: Mapping[str, Any],
226
+ verification: dict[str, Any],
227
+ input_data: bytes,
228
+ input_revision: str,
229
+ output_path: Path,
230
+ ) -> None:
231
+ verification["revision"] = {
232
+ "expected": normalized["expectedRevision"],
233
+ "actual": input_revision,
234
+ "matched": normalized["expectedRevision"] in {None, input_revision},
235
+ }
236
+ if normalized["expectedRevision"] not in {None, input_revision}:
237
+ raise AgentContractError(
238
+ "stale_revision", "expectedRevision does not match input bytes", target="batch.expectedRevision"
239
+ )
240
+ input_safety = validate_editor_open_safety(input_data)
241
+ verification["inputOpenSafety"] = input_safety.to_dict()
242
+ if not input_safety.ok:
243
+ raise AgentContractError(
244
+ "verification_failed",
245
+ "input failed package/reopen/openSafety verification",
246
+ target="batch.input.filename",
247
+ )
248
+ if output_path.exists() and not normalized["output"]["overwrite"] and not normalized["dryRun"]:
249
+ raise AgentContractError(
250
+ "invariant_violation", "output exists and overwrite is false", target="batch.output.filename"
251
+ )
252
+
253
+
254
+ def _apply_commands_build_candidate_report(
255
+ input_data: bytes,
256
+ candidate_data: bytes,
257
+ input_revision: str,
258
+ semantic_changes: list[Mapping[str, Any]],
259
+ identity_changes: list[Mapping[str, str]],
260
+ story_expectations: Mapping[str, Mapping[str, str]],
261
+ verification: dict[str, Any],
262
+ ) -> tuple[str, dict[str, Any], Any, dict[str, Any]]:
263
+ """Compute the candidate revision/semantic diff and write byte/package safety
264
+ into ``verification``.
265
+
266
+ Does not raise on unsafe candidates -- the original ordering runs domain
267
+ verification before that check, so callers must still inspect the
268
+ returned ``safety``/``byte_report``.
269
+ """
270
+
271
+ candidate_revision = _revision(candidate_data)
272
+ if story_expectations:
273
+ verification["storyPreservation"] = _verify_header_story_candidates(
274
+ candidate_data,
275
+ candidate_revision,
276
+ story_expectations,
277
+ )
278
+ semantic_diff = {
279
+ "schemaVersion": "hwpx.agent-semantic-diff/v1",
280
+ "inputRevision": input_revision,
281
+ "candidateRevision": candidate_revision,
282
+ "changes": semantic_changes,
283
+ "identityMap": identity_changes,
284
+ }
285
+ byte_report = _member_diff(input_data, candidate_data)
286
+ safety = validate_editor_open_safety(candidate_data)
287
+ safety_dict = safety.to_dict()
288
+ verification.update(
289
+ {
290
+ "candidateRevision": candidate_revision,
291
+ "package": safety_dict["validatePackage"],
292
+ "reopen": safety_dict["reopen"],
293
+ "openSafety": safety_dict,
294
+ "semanticDiff": {"ok": True, "changeCount": len(semantic_changes)},
295
+ "bytePreservation": byte_report,
296
+ }
297
+ )
298
+ return candidate_revision, semantic_diff, safety, byte_report
299
+
300
+
301
+ def _apply_commands_domain_verification(
302
+ candidate_data: bytes,
303
+ normalized: Mapping[str, Any],
304
+ requirements: set[str],
305
+ domain_verifier: DomainVerifier | None,
306
+ verification: dict[str, Any],
307
+ ) -> None:
308
+ if "domain" in requirements:
309
+ if domain_verifier is None:
310
+ verification["domain"] = {"ok": False, "error": "required verifier unavailable"}
311
+ raise AgentContractError(
312
+ "verification_failed", "required domain verifier is unavailable", target="domain"
313
+ )
314
+ domain_report = dict(domain_verifier(candidate_data, normalized))
315
+ try:
316
+ json.dumps(domain_report, ensure_ascii=False, sort_keys=True)
317
+ except (TypeError, ValueError) as exc:
318
+ raise AgentContractError(
319
+ "verification_failed",
320
+ "domain verifier returned a non-JSON report",
321
+ target="domain",
322
+ ) from exc
323
+ verification["domain"] = domain_report
324
+ if not domain_report.get("ok"):
325
+ raise AgentContractError("verification_failed", "domain verification failed", target="domain")
326
+ else:
327
+ verification["domain"] = {"ok": None, "status": "not-requested"}
328
+
329
+
330
+ def _require_candidate_structural_safety(safety: Any, byte_report: Mapping[str, Any]) -> None:
331
+ if not safety.ok or not byte_report.get("ok"):
332
+ raise AgentContractError(
333
+ "verification_failed", "candidate failed package/reopen/openSafety verification"
334
+ )
335
+
336
+
337
+ def _apply_commands_run_save_pipeline(
338
+ candidate_data: bytes,
339
+ input_path: Path,
340
+ output_path: Path,
341
+ document: HwpxDocument,
342
+ normalized: Mapping[str, Any],
343
+ requirements: set[str],
344
+ save_pipeline: SavePipeline | None,
345
+ verification: dict[str, Any],
346
+ ) -> Any:
347
+ pipeline = save_pipeline or SavePipeline()
348
+ quality_policy = _quality_policy(normalized["quality"])
349
+ if "realHancom" in requirements:
350
+ # Make the required oracle part of the atomic gate itself. A
351
+ # post-publication provenance check would be too late to roll
352
+ # back an otherwise structurally valid file.
353
+ quality_policy = quality_policy.with_(
354
+ require_visual_complete=True,
355
+ render_check="required",
356
+ )
357
+ quality_report = pipeline.run(
358
+ candidate_data,
359
+ output_path=None if normalized["dryRun"] else output_path,
360
+ quality=quality_policy,
361
+ before=input_path,
362
+ reference_document=document,
363
+ publish="never" if normalized["dryRun"] else "on_pass",
364
+ source_label="agent.apply_document_commands",
365
+ )
366
+ verification["savePipeline"] = quality_report.to_dict()
367
+ verification["realHancom"] = {
368
+ "required": "realHancom" in requirements,
369
+ "ok": quality_report.visual_complete,
370
+ "status": quality_report.visual_complete_status,
371
+ "renderChecked": quality_report.render_checked,
372
+ }
373
+ if "realHancom" in requirements and not quality_report.visual_complete:
374
+ raise AgentContractError(
375
+ "verification_failed",
376
+ "required real-Hancom visual verification is unavailable or failed",
377
+ target="realHancom",
378
+ )
379
+ if not quality_report.ok:
380
+ raise AgentContractError(
381
+ "verification_failed", "SavePipeline rejected the candidate", target="savePipeline"
382
+ )
383
+ return quality_report