python-hwpx-automation 6.0.3__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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,173 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Shared render contracts — the leaf both queue and transport sides import.
3
+
4
+ These contracts were extracted from :mod:`rendering`/:mod:`render_queue` to remove the
5
+ last package import cycle: the v2 job/receipt/status models and the submission
6
+ signature live here, and both former owners re-export them for compatibility.
7
+ This module may import only the standard library and pydantic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ from enum import Enum
15
+ from typing import Literal
16
+
17
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
18
+
19
+
20
+ RENDER_SCHEMA_VERSION_V2 = "hwpx.render.v2"
21
+ SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$"
22
+
23
+
24
+ class RenderStatus(str, Enum):
25
+ QUEUED = "queued"
26
+ RUNNING = "running"
27
+ SUCCEEDED = "succeeded"
28
+ FAILED = "failed"
29
+ UNAVAILABLE = "unavailable"
30
+ CANCELLED = "cancelled"
31
+
32
+
33
+ class RenderArtifactKind(str, Enum):
34
+ PDF = "pdf"
35
+ PAGE_PNG = "page_png"
36
+
37
+
38
+ class RenderArtifactV2(BaseModel):
39
+ """Content-addressed output; worker-local paths never cross the boundary."""
40
+
41
+ model_config = ConfigDict(extra="forbid", frozen=True)
42
+
43
+ kind: RenderArtifactKind
44
+ content_hash: str = Field(pattern=SHA256_PATTERN)
45
+ size_bytes: int = Field(ge=1)
46
+ page_number: int | None = Field(default=None, ge=1)
47
+
48
+ @model_validator(mode="after")
49
+ def page_number_matches_kind(self) -> "RenderArtifactV2":
50
+ if self.kind == RenderArtifactKind.PAGE_PNG and self.page_number is None:
51
+ raise ValueError("page_png artifact requires page_number")
52
+ if self.kind == RenderArtifactKind.PDF and self.page_number is not None:
53
+ raise ValueError("pdf artifact cannot have page_number")
54
+ return self
55
+
56
+
57
+ class RenderJobV2(BaseModel):
58
+ """Measure-first, content-addressed real-Hancom queue request."""
59
+
60
+ model_config = ConfigDict(extra="forbid", frozen=True)
61
+
62
+ schema_version: Literal[RENDER_SCHEMA_VERSION_V2] = RENDER_SCHEMA_VERSION_V2
63
+ # Colon is deliberately excluded: it is not safe in Windows directory names.
64
+ job_id: str = Field(min_length=8, max_length=200, pattern=r"^[A-Za-z0-9._-]+$")
65
+ workflow_id: str = Field(min_length=8, max_length=200, pattern=r"^[A-Za-z0-9._-]+$")
66
+ idempotency_key: str = Field(min_length=8, max_length=200)
67
+ source_content_hash: str = Field(pattern=SHA256_PATTERN)
68
+ source_size_bytes: int = Field(gt=0)
69
+ source_media_type: Literal["application/hwp+zip"] = "application/hwp+zip"
70
+ submitted_at: AwareDatetime
71
+ requested_backend: Literal["real_hancom"] = "real_hancom"
72
+ requested_outputs: tuple[RenderArtifactKind, ...] = (
73
+ RenderArtifactKind.PDF,
74
+ RenderArtifactKind.PAGE_PNG,
75
+ )
76
+ dpi: int = Field(default=144, ge=72, le=600)
77
+
78
+ @field_validator("requested_outputs")
79
+ @classmethod
80
+ def outputs_are_unique_and_nonempty(
81
+ cls, value: tuple[RenderArtifactKind, ...]
82
+ ) -> tuple[RenderArtifactKind, ...]:
83
+ if set(value) != {RenderArtifactKind.PDF, RenderArtifactKind.PAGE_PNG} or len(value) != 2:
84
+ raise ValueError("v2 requires exactly PDF and page_png outputs")
85
+ return value
86
+
87
+
88
+ class RenderReceiptV2(BaseModel):
89
+ """Terminal/non-terminal receipt bound to input and real worker provenance."""
90
+
91
+ model_config = ConfigDict(extra="forbid", frozen=True)
92
+
93
+ schema_version: Literal[RENDER_SCHEMA_VERSION_V2] = RENDER_SCHEMA_VERSION_V2
94
+ job_id: str = Field(min_length=8, max_length=200)
95
+ workflow_id: str = Field(min_length=8, max_length=200)
96
+ input_content_hash: str = Field(pattern=SHA256_PATTERN)
97
+ status: RenderStatus
98
+ backend: str | None = None
99
+ hancom_build: str | None = None
100
+ worker_version: str | None = None
101
+ queued_at: AwareDatetime
102
+ started_at: AwareDatetime | None = None
103
+ completed_at: AwareDatetime | None = None
104
+ artifacts: tuple[RenderArtifactV2, ...] = ()
105
+ page_count: int | None = Field(default=None, ge=0)
106
+ retry_count: int = Field(default=0, ge=0)
107
+ terminal_reason: str | None = None
108
+ render_checked: bool = False
109
+
110
+ @model_validator(mode="after")
111
+ def validate_provenance_and_timeline(self) -> "RenderReceiptV2":
112
+ terminal = self.status in {
113
+ RenderStatus.SUCCEEDED,
114
+ RenderStatus.FAILED,
115
+ RenderStatus.UNAVAILABLE,
116
+ RenderStatus.CANCELLED,
117
+ }
118
+ if self.started_at is not None and self.started_at < self.queued_at:
119
+ raise ValueError("started_at cannot precede queued_at")
120
+ if self.completed_at is not None and self.completed_at < (self.started_at or self.queued_at):
121
+ raise ValueError("completed_at must preserve monotonic timestamps")
122
+ if terminal and (self.completed_at is None or not self.terminal_reason):
123
+ raise ValueError("terminal receipt requires completed_at and terminal_reason")
124
+ if not terminal and (self.completed_at is not None or self.terminal_reason is not None):
125
+ raise ValueError("non-terminal receipt cannot carry terminal completion fields")
126
+
127
+ page_artifacts = [item for item in self.artifacts if item.kind == RenderArtifactKind.PAGE_PNG]
128
+ page_numbers = [item.page_number for item in page_artifacts]
129
+ if len(page_numbers) != len(set(page_numbers)):
130
+ raise ValueError("page artifact numbers must be unique")
131
+ if self.status == RenderStatus.SUCCEEDED:
132
+ if self.started_at is None:
133
+ raise ValueError("successful receipt requires started_at")
134
+ if not page_artifacts or self.page_count != len(page_artifacts):
135
+ raise ValueError("successful receipt requires artifacts and exact page_count")
136
+ if page_numbers != list(range(1, len(page_numbers) + 1)):
137
+ raise ValueError("successful receipt requires contiguous page numbers starting at one")
138
+ pdf_count = sum(item.kind == RenderArtifactKind.PDF for item in self.artifacts)
139
+ if pdf_count != 1:
140
+ raise ValueError("successful receipt requires exactly one PDF artifact")
141
+ if not self.render_checked or not self.backend or not self.hancom_build or not self.worker_version:
142
+ raise ValueError(
143
+ "successful receipt requires render_checked and backend/Hancom/worker provenance"
144
+ )
145
+ elif self.artifacts or self.page_count not in (None, 0):
146
+ raise ValueError("non-success receipt cannot claim output artifacts")
147
+
148
+ if self.render_checked and (
149
+ self.status != RenderStatus.SUCCEEDED
150
+ or not self.backend
151
+ or not self.hancom_build
152
+ or not self.worker_version
153
+ ):
154
+ raise ValueError(
155
+ "render_checked requires succeeded status and backend/Hancom/worker provenance"
156
+ )
157
+ return self
158
+
159
+ def binds(self, job: RenderJobV2) -> bool:
160
+ """Return true only when identity, workflow, and content address match."""
161
+
162
+ return (
163
+ self.job_id == job.job_id
164
+ and self.workflow_id == job.workflow_id
165
+ and self.input_content_hash == job.source_content_hash
166
+ )
167
+
168
+
169
+ def sign_submission(secret: bytes, job: RenderJobV2) -> str:
170
+ """Sign only immutable request metadata; document bytes are bound by SHA-256."""
171
+
172
+ message = job.model_dump_json(exclude_none=False).encode("utf-8")
173
+ return "sha256=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
@@ -0,0 +1,196 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Frozen measure-first corpus and accounting rules for the render service."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import math
9
+ from collections import Counter
10
+ from typing import Literal, Sequence
11
+
12
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
13
+
14
+ from .rendering import RenderStatus
15
+
16
+
17
+ class CorpusBucket(BaseModel):
18
+ model_config = ConfigDict(extra="forbid", frozen=True)
19
+
20
+ key: str
21
+ document_count: int = Field(gt=0)
22
+ eligible_for_success_availability: bool = True
23
+
24
+
25
+ class BaselineCorpusContract(BaseModel):
26
+ model_config = ConfigDict(extra="forbid", frozen=True)
27
+
28
+ schema_version: Literal["hwpx.render-baseline.v1"] = "hwpx.render-baseline.v1"
29
+ corpus_id: Literal["s068-mixed-100-v1"] = "s068-mixed-100-v1"
30
+ buckets: tuple[CorpusBucket, ...]
31
+
32
+ @model_validator(mode="after")
33
+ def exactly_one_hundred_unique_documents(self) -> "BaselineCorpusContract":
34
+ if sum(item.document_count for item in self.buckets) != 100:
35
+ raise ValueError("render baseline corpus must contain exactly 100 documents")
36
+ keys = [item.key for item in self.buckets]
37
+ if len(keys) != len(set(keys)):
38
+ raise ValueError("render baseline bucket keys must be unique")
39
+ return self
40
+
41
+ @property
42
+ def contract_hash(self) -> str:
43
+ payload = json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
44
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
45
+
46
+
47
+ FROZEN_BASELINE_CORPUS = BaselineCorpusContract(
48
+ buckets=(
49
+ CorpusBucket(key="simple_text", document_count=15),
50
+ CorpusBucket(key="tables_and_merges", document_count=20),
51
+ CorpusBucket(key="known_templates", document_count=15),
52
+ CorpusBucket(key="unknown_forms", document_count=15),
53
+ CorpusBucket(key="images_and_shapes", document_count=10),
54
+ CorpusBucket(key="multi_section_multipage", document_count=10),
55
+ CorpusBucket(key="large_near_limit", document_count=10),
56
+ CorpusBucket(key="invalid_or_unsupported", document_count=5, eligible_for_success_availability=False),
57
+ )
58
+ )
59
+
60
+
61
+ class MeasureFirstGate(BaseModel):
62
+ """Only accounting targets are gates until observed latency/availability is frozen."""
63
+
64
+ model_config = ConfigDict(extra="forbid", frozen=True)
65
+
66
+ corpus_documents: Literal[100] = 100
67
+ lost_job_target: Literal[0] = 0
68
+ duplicate_terminal_receipt_target: Literal[0] = 0
69
+ terminal_accounting_rate_target: Literal[1.0] = 1.0
70
+ latency_percentiles: tuple[Literal["p50", "p95", "p99"], ...] = ("p50", "p95", "p99")
71
+ availability_target: None = None
72
+
73
+
74
+ class RenderMetricObservation(BaseModel):
75
+ model_config = ConfigDict(extra="forbid", frozen=True)
76
+
77
+ receipt_id: str = Field(min_length=8)
78
+ job_id: str = Field(min_length=8)
79
+ submitted_at: AwareDatetime
80
+ terminal_at: AwareDatetime
81
+ status: RenderStatus
82
+ eligible_for_success_availability: bool = True
83
+
84
+ @model_validator(mode="after")
85
+ def terminal_and_monotonic(self) -> "RenderMetricObservation":
86
+ if self.status not in {
87
+ RenderStatus.SUCCEEDED,
88
+ RenderStatus.FAILED,
89
+ RenderStatus.UNAVAILABLE,
90
+ RenderStatus.CANCELLED,
91
+ }:
92
+ raise ValueError("metric observation must be terminal")
93
+ if self.terminal_at < self.submitted_at:
94
+ raise ValueError("terminal_at cannot precede submitted_at")
95
+ return self
96
+
97
+ @property
98
+ def latency_seconds(self) -> float:
99
+ return (self.terminal_at - self.submitted_at).total_seconds()
100
+
101
+
102
+ class QueueMetricSample(BaseModel):
103
+ model_config = ConfigDict(extra="forbid", frozen=True)
104
+
105
+ observed_at: AwareDatetime
106
+ queue_depth: int = Field(ge=0)
107
+ oldest_queued_age_seconds: float = Field(ge=0)
108
+ running_jobs: int = Field(ge=0, le=1)
109
+ last_success_at: AwareDatetime | None = None
110
+ worker_available: bool
111
+ degraded_reason: str | None = None
112
+
113
+ @model_validator(mode="after")
114
+ def degraded_state_is_explicit(self) -> "QueueMetricSample":
115
+ if not self.worker_available and not self.degraded_reason:
116
+ raise ValueError("unavailable worker requires degraded_reason")
117
+ return self
118
+
119
+
120
+ class RenderAccountingReport(BaseModel):
121
+ model_config = ConfigDict(extra="forbid", frozen=True)
122
+
123
+ expected_jobs: int
124
+ unique_terminal_jobs: int
125
+ terminal_receipts: int
126
+ missing_job_ids: tuple[str, ...]
127
+ duplicate_job_ids: tuple[str, ...]
128
+ orphan_job_ids: tuple[str, ...]
129
+ lost_jobs: int
130
+ duplicate_terminal_receipts: int
131
+ terminal_accounting_rate: float
132
+ success_availability: float | None
133
+ latency_seconds: dict[str, float | None]
134
+ exact_accounting: bool
135
+
136
+
137
+ def _percentile(values: Sequence[float], percentile: float) -> float | None:
138
+ if not values:
139
+ return None
140
+ ordered = sorted(values)
141
+ rank = max(0, math.ceil(percentile * len(ordered)) - 1)
142
+ return ordered[rank]
143
+
144
+
145
+ def account_terminal_receipts(
146
+ expected_job_ids: Sequence[str],
147
+ observations: Sequence[RenderMetricObservation],
148
+ ) -> RenderAccountingReport:
149
+ """Account every expected job exactly once; duplicates never hide losses."""
150
+
151
+ expected = set(expected_job_ids)
152
+ if len(expected) != len(expected_job_ids):
153
+ raise ValueError("expected_job_ids must be unique")
154
+ counts = Counter(item.job_id for item in observations)
155
+ observed = set(counts)
156
+ missing = tuple(sorted(expected - observed))
157
+ duplicates = tuple(sorted(job_id for job_id, count in counts.items() if job_id in expected and count > 1))
158
+ orphans = tuple(sorted(observed - expected))
159
+ canonical = [item for item in observations if item.job_id in expected and counts[item.job_id] == 1]
160
+ eligible = [item for item in canonical if item.eligible_for_success_availability]
161
+ successes = sum(item.status == RenderStatus.SUCCEEDED for item in eligible)
162
+ exact = not missing and not duplicates and not orphans
163
+ availability = successes / len(eligible) if exact and eligible else None
164
+ latencies = [item.latency_seconds for item in canonical]
165
+ duplicate_receipts = sum(max(0, counts[job_id] - 1) for job_id in expected)
166
+ accounted = len(expected & observed)
167
+ return RenderAccountingReport(
168
+ expected_jobs=len(expected),
169
+ unique_terminal_jobs=accounted,
170
+ terminal_receipts=len(observations),
171
+ missing_job_ids=missing,
172
+ duplicate_job_ids=duplicates,
173
+ orphan_job_ids=orphans,
174
+ lost_jobs=len(missing),
175
+ duplicate_terminal_receipts=duplicate_receipts,
176
+ terminal_accounting_rate=accounted / len(expected) if expected else 1.0,
177
+ success_availability=availability,
178
+ latency_seconds={
179
+ "p50": _percentile(latencies, 0.50),
180
+ "p95": _percentile(latencies, 0.95),
181
+ "p99": _percentile(latencies, 0.99),
182
+ },
183
+ exact_accounting=exact,
184
+ )
185
+
186
+
187
+ __all__ = [
188
+ "BaselineCorpusContract",
189
+ "CorpusBucket",
190
+ "FROZEN_BASELINE_CORPUS",
191
+ "MeasureFirstGate",
192
+ "QueueMetricSample",
193
+ "RenderAccountingReport",
194
+ "RenderMetricObservation",
195
+ "account_terminal_receipts",
196
+ ]