flowspec2 1.0.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 (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1013 @@
1
+ """Closed human-review contracts for author-authored presentation prose."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Final, Literal, cast
11
+
12
+ import jsonschema
13
+
14
+ from flowspec2.json_codec import strict_json_loads
15
+
16
+ from .corpus import load_reference_authoring_corpus
17
+ from .evidence_verification import AuthoringEvidenceVerification
18
+
19
+ PRESENTATION_RUBRIC_FORMAT: Final[str] = "flowspec2/authoring-presentation-rubric@1"
20
+ PRESENTATION_RUBRIC_IDENTIFIER: Final[str] = "flowspec2_citizen_presentation"
21
+ PRESENTATION_REVIEW_FORMAT: Final[str] = "flowspec2/authoring-presentation-review@1"
22
+ PRESENTATION_REVIEW_DRAFT_FORMAT: Final[str] = "flowspec2/authoring-presentation-review-draft@1"
23
+ _PRESENTATION_REVIEW_SCHEMA_IDENTIFIER: Final[str] = (
24
+ "https://wllsena.github.io/flowspec2/schemas/authoring-presentation-review-1.json"
25
+ )
26
+
27
+ PresentationSubjectKind = Literal["route_description", "path_prompt", "confirm_prompt"]
28
+ PresentationDecision = Literal["pass", "fail"]
29
+
30
+ _IDENTIFIER_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
31
+ _DIGEST_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$")
32
+
33
+
34
+ def _canonical_json(json_document: object) -> str:
35
+ return json.dumps(
36
+ json_document,
37
+ ensure_ascii=False,
38
+ allow_nan=False,
39
+ separators=(",", ":"),
40
+ sort_keys=True,
41
+ )
42
+
43
+
44
+ def _sha256(serialized_contract: str) -> str:
45
+ return hashlib.sha256(serialized_contract.encode("utf-8")).hexdigest()
46
+
47
+
48
+ def _json_pointer_segment(property_name: str) -> str:
49
+ return property_name.replace("~", "~0").replace("/", "~1")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PresentationRubricCriterion:
54
+ """One fixed public question applied to a presentation subject kind."""
55
+
56
+ identifier: str
57
+ subject_kinds: tuple[PresentationSubjectKind, ...]
58
+ question: str
59
+
60
+ def __post_init__(self) -> None:
61
+ if not _IDENTIFIER_PATTERN.fullmatch(self.identifier):
62
+ raise ValueError("presentation criterion identifier is invalid")
63
+ if not self.subject_kinds or len(set(self.subject_kinds)) != len(self.subject_kinds):
64
+ raise ValueError("presentation criterion subject kinds must be non-empty and unique")
65
+ if not self.question.strip():
66
+ raise ValueError("presentation criterion question must be non-empty")
67
+ object.__setattr__(self, "subject_kinds", tuple(sorted(self.subject_kinds)))
68
+ object.__setattr__(self, "question", self.question.strip())
69
+
70
+ def to_dict(self) -> dict[str, object]:
71
+ return {
72
+ "identifier": self.identifier,
73
+ "subject_kinds": list(self.subject_kinds),
74
+ "question": self.question,
75
+ }
76
+
77
+
78
+ PRESENTATION_RUBRIC_CRITERIA: Final[tuple[PresentationRubricCriterion, ...]] = (
79
+ PresentationRubricCriterion(
80
+ identifier="prompt_claim_fidelity",
81
+ subject_kinds=("path_prompt", "confirm_prompt"),
82
+ question=(
83
+ "Does the prompt avoid implying completion, authorization, privacy, or backend "
84
+ "outcomes that the public runtime contract does not guarantee?"
85
+ ),
86
+ ),
87
+ PresentationRubricCriterion(
88
+ identifier="prompt_input_contract_fidelity",
89
+ subject_kinds=("path_prompt", "confirm_prompt"),
90
+ question=(
91
+ "Does the prompt request only information or action supported by its public input "
92
+ "and interaction contract?"
93
+ ),
94
+ ),
95
+ PresentationRubricCriterion(
96
+ identifier="prompt_interaction_context",
97
+ subject_kinds=("path_prompt", "confirm_prompt"),
98
+ question="Does the prompt give the citizen enough context to answer at this point?",
99
+ ),
100
+ PresentationRubricCriterion(
101
+ identifier="prompt_requested_action_clarity",
102
+ subject_kinds=("path_prompt", "confirm_prompt"),
103
+ question="Does the prompt clearly state what the citizen should provide, choose, or do?",
104
+ ),
105
+ PresentationRubricCriterion(
106
+ identifier="route_claim_fidelity",
107
+ subject_kinds=("route_description",),
108
+ question=(
109
+ "Does the route description avoid capabilities or guarantees absent from the public "
110
+ "flow contract?"
111
+ ),
112
+ ),
113
+ PresentationRubricCriterion(
114
+ identifier="route_scope_coverage",
115
+ subject_kinds=("route_description",),
116
+ question="Does the route description cover the supported citizen intent?",
117
+ ),
118
+ PresentationRubricCriterion(
119
+ identifier="route_scope_exclusion",
120
+ subject_kinds=("route_description",),
121
+ question="Does the route description avoid adjacent or unsupported service intents?",
122
+ ),
123
+ PresentationRubricCriterion(
124
+ identifier="route_standalone_meaning",
125
+ subject_kinds=("route_description",),
126
+ question=(
127
+ "Is the route description understandable without private identifiers or implementation "
128
+ "context?"
129
+ ),
130
+ ),
131
+ )
132
+
133
+
134
+ def _rubric_contract() -> dict[str, object]:
135
+ return {
136
+ "format": PRESENTATION_RUBRIC_FORMAT,
137
+ "identifier": PRESENTATION_RUBRIC_IDENTIFIER,
138
+ "criteria": [criterion.to_dict() for criterion in PRESENTATION_RUBRIC_CRITERIA],
139
+ }
140
+
141
+
142
+ PRESENTATION_RUBRIC_DIGEST: Final[str] = _sha256(_canonical_json(_rubric_contract()))
143
+
144
+
145
+ def presentation_rubric() -> dict[str, object]:
146
+ """Return an owned representation of the fixed public rubric."""
147
+
148
+ return cast(dict[str, object], json.loads(_canonical_json(_rubric_contract())))
149
+
150
+
151
+ def _criteria_for_kind(
152
+ subject_kind: PresentationSubjectKind,
153
+ ) -> tuple[PresentationRubricCriterion, ...]:
154
+ return tuple(
155
+ criterion
156
+ for criterion in PRESENTATION_RUBRIC_CRITERIA
157
+ if subject_kind in criterion.subject_kinds
158
+ )
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class PresentationSubject:
163
+ """Content identity of one candidate-authored presentation string."""
164
+
165
+ case_identifier: str
166
+ correction_round: int
167
+ source_sha256: str
168
+ kind: PresentationSubjectKind
169
+ pointer: str
170
+ text_sha256: str
171
+
172
+ def __post_init__(self) -> None:
173
+ if not _IDENTIFIER_PATTERN.fullmatch(self.case_identifier):
174
+ raise ValueError("presentation subject case identifier is invalid")
175
+ if self.correction_round < 0:
176
+ raise ValueError("presentation subject correction round must not be negative")
177
+ if not _DIGEST_PATTERN.fullmatch(self.source_sha256):
178
+ raise ValueError("presentation subject source digest must be lowercase SHA-256")
179
+ if self.kind not in {"route_description", "path_prompt", "confirm_prompt"}:
180
+ raise ValueError("presentation subject kind is unsupported")
181
+ if not self.pointer.startswith("/"):
182
+ raise ValueError("presentation subject pointer must be a JSON Pointer")
183
+ if not _DIGEST_PATTERN.fullmatch(self.text_sha256):
184
+ raise ValueError("presentation subject text digest must be lowercase SHA-256")
185
+
186
+ @property
187
+ def identity(self) -> tuple[str, int, str, str]:
188
+ return (
189
+ self.case_identifier,
190
+ self.correction_round,
191
+ self.pointer,
192
+ self.kind,
193
+ )
194
+
195
+ def to_dict(self) -> dict[str, object]:
196
+ return {
197
+ "case_identifier": self.case_identifier,
198
+ "correction_round": self.correction_round,
199
+ "source_sha256": self.source_sha256,
200
+ "kind": self.kind,
201
+ "pointer": self.pointer,
202
+ "text_sha256": self.text_sha256,
203
+ }
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class PresentationCriterionResult:
208
+ """One attributable human decision under the fixed rubric."""
209
+
210
+ criterion_identifier: str
211
+ decision: PresentationDecision
212
+ rationale: str
213
+
214
+ def __post_init__(self) -> None:
215
+ if not _IDENTIFIER_PATTERN.fullmatch(self.criterion_identifier):
216
+ raise ValueError("presentation result criterion identifier is invalid")
217
+ if self.decision not in {"pass", "fail"}:
218
+ raise ValueError("presentation result decision is unsupported")
219
+ if not self.rationale.strip():
220
+ raise ValueError("presentation result rationale must be non-empty")
221
+ object.__setattr__(self, "rationale", self.rationale.strip())
222
+
223
+ def to_dict(self) -> dict[str, str]:
224
+ return {
225
+ "criterion_identifier": self.criterion_identifier,
226
+ "decision": self.decision,
227
+ "rationale": self.rationale,
228
+ }
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class PresentationAssessment:
233
+ """Exact criterion closure for one candidate presentation subject."""
234
+
235
+ subject: PresentationSubject
236
+ criteria: tuple[PresentationCriterionResult, ...]
237
+
238
+ def __post_init__(self) -> None:
239
+ criteria = tuple(sorted(self.criteria, key=lambda result: result.criterion_identifier))
240
+ actual_identifiers = tuple(result.criterion_identifier for result in criteria)
241
+ expected_identifiers = tuple(
242
+ criterion.identifier for criterion in _criteria_for_kind(self.subject.kind)
243
+ )
244
+ if actual_identifiers != expected_identifiers:
245
+ raise ValueError(
246
+ "presentation assessment criteria do not match the fixed rubric for its subject"
247
+ )
248
+ object.__setattr__(self, "criteria", criteria)
249
+
250
+ @property
251
+ def passed(self) -> bool:
252
+ return all(result.decision == "pass" for result in self.criteria)
253
+
254
+ def to_dict(self) -> dict[str, object]:
255
+ return {
256
+ "subject": self.subject.to_dict(),
257
+ "criteria": [result.to_dict() for result in self.criteria],
258
+ "passed": self.passed,
259
+ }
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class AuthoringPresentationReview:
264
+ """Canonical content-addressed human review bound to benchmark evidence."""
265
+
266
+ benchmark_evidence_digest: str
267
+ reviewer_identifier: str
268
+ assessments: tuple[PresentationAssessment, ...]
269
+ format_identifier: str = PRESENTATION_REVIEW_FORMAT
270
+ rubric_format: str = PRESENTATION_RUBRIC_FORMAT
271
+ rubric_identifier: str = PRESENTATION_RUBRIC_IDENTIFIER
272
+ rubric_digest: str = PRESENTATION_RUBRIC_DIGEST
273
+
274
+ def __post_init__(self) -> None:
275
+ if self.format_identifier != PRESENTATION_REVIEW_FORMAT:
276
+ raise ValueError("presentation review format is unsupported")
277
+ if not _DIGEST_PATTERN.fullmatch(self.benchmark_evidence_digest):
278
+ raise ValueError("presentation review evidence digest must be lowercase SHA-256")
279
+ if not _IDENTIFIER_PATTERN.fullmatch(self.reviewer_identifier):
280
+ raise ValueError("presentation review reviewer identifier is invalid")
281
+ if (
282
+ self.rubric_format != PRESENTATION_RUBRIC_FORMAT
283
+ or self.rubric_identifier != PRESENTATION_RUBRIC_IDENTIFIER
284
+ or self.rubric_digest != PRESENTATION_RUBRIC_DIGEST
285
+ ):
286
+ raise ValueError("presentation review rubric does not match the fixed public rubric")
287
+ assessments = tuple(sorted(self.assessments, key=lambda item: item.subject.identity))
288
+ if not assessments:
289
+ raise ValueError("presentation review requires assessments")
290
+ subject_identities = tuple(assessment.subject.identity for assessment in assessments)
291
+ if len(set(subject_identities)) != len(subject_identities):
292
+ raise ValueError("presentation review subjects must be unique")
293
+ object.__setattr__(self, "assessments", assessments)
294
+
295
+ @property
296
+ def passed(self) -> bool:
297
+ return all(assessment.passed for assessment in self.assessments)
298
+
299
+ def _unsigned_dict(self) -> dict[str, object]:
300
+ return {
301
+ "format": self.format_identifier,
302
+ "benchmark_evidence_digest": self.benchmark_evidence_digest,
303
+ "rubric": {
304
+ "format": self.rubric_format,
305
+ "identifier": self.rubric_identifier,
306
+ "digest": self.rubric_digest,
307
+ },
308
+ "reviewer_identifier": self.reviewer_identifier,
309
+ "assessments": [assessment.to_dict() for assessment in self.assessments],
310
+ "passed": self.passed,
311
+ }
312
+
313
+ @property
314
+ def digest(self) -> str:
315
+ return _sha256(_canonical_json(self._unsigned_dict()))
316
+
317
+ def to_dict(self) -> dict[str, object]:
318
+ review_document = self._unsigned_dict()
319
+ review_document["digest"] = self.digest
320
+ return review_document
321
+
322
+ def to_json(self) -> str:
323
+ return _canonical_json(self.to_dict())
324
+
325
+ @classmethod
326
+ def from_json(cls, serialized_review: str) -> AuthoringPresentationReview:
327
+ """Parse a canonical closed review and verify all derived fields."""
328
+
329
+ decoded_review = strict_json_loads(serialized_review)
330
+ jsonschema.Draft202012Validator(_review_schema()).validate(decoded_review)
331
+ if not isinstance(decoded_review, dict):
332
+ raise AssertionError("the presentation review schema accepted a non-object")
333
+ review_document = cast(dict[str, Any], decoded_review)
334
+ if serialized_review.strip() != _canonical_json(review_document):
335
+ raise ValueError("presentation review must use canonical compact JSON")
336
+ rubric_document = cast(dict[str, str], review_document["rubric"])
337
+ assessments = tuple(
338
+ PresentationAssessment(
339
+ subject=PresentationSubject(**cast(dict[str, Any], assessment["subject"])),
340
+ criteria=tuple(
341
+ PresentationCriterionResult(**criterion_result)
342
+ for criterion_result in cast(list[dict[str, Any]], assessment["criteria"])
343
+ ),
344
+ )
345
+ for assessment in cast(list[dict[str, Any]], review_document["assessments"])
346
+ )
347
+ review = cls(
348
+ benchmark_evidence_digest=cast(str, review_document["benchmark_evidence_digest"]),
349
+ reviewer_identifier=cast(str, review_document["reviewer_identifier"]),
350
+ assessments=assessments,
351
+ format_identifier=cast(str, review_document["format"]),
352
+ rubric_format=rubric_document["format"],
353
+ rubric_identifier=rubric_document["identifier"],
354
+ rubric_digest=rubric_document["digest"],
355
+ )
356
+ if review_document["passed"] is not review.passed:
357
+ raise ValueError("presentation review pass state is not derived from its criteria")
358
+ if review_document["digest"] != review.digest:
359
+ raise ValueError("presentation review digest does not match its content")
360
+ return review
361
+
362
+
363
+ @dataclass(frozen=True)
364
+ class PresentationReviewTaskContext:
365
+ """Source-free public task context shown to a presentation reviewer."""
366
+
367
+ task_identifier: str
368
+ prompt: str
369
+ acceptance_json: str
370
+
371
+ def __post_init__(self) -> None:
372
+ if not _IDENTIFIER_PATTERN.fullmatch(self.task_identifier):
373
+ raise ValueError("presentation task context identifier is invalid")
374
+ if not self.prompt.strip():
375
+ raise ValueError("presentation task context prompt must be non-empty")
376
+ acceptance_document = strict_json_loads(self.acceptance_json)
377
+ if not isinstance(acceptance_document, dict):
378
+ raise ValueError("presentation task acceptance must be a JSON object")
379
+ if _canonical_json(acceptance_document) != self.acceptance_json:
380
+ raise ValueError("presentation task acceptance must be canonical JSON")
381
+ if any(
382
+ forbidden_property in acceptance_document
383
+ for forbidden_property in ("expected_flow_json", "feature_tags", "source")
384
+ ):
385
+ raise ValueError("presentation task context contains a private oracle field")
386
+ object.__setattr__(self, "prompt", self.prompt.strip())
387
+
388
+ def to_dict(self) -> dict[str, object]:
389
+ return {
390
+ "task_identifier": self.task_identifier,
391
+ "prompt": self.prompt,
392
+ "acceptance": strict_json_loads(self.acceptance_json),
393
+ }
394
+
395
+
396
+ @dataclass(frozen=True)
397
+ class PresentationReviewDraftSubject:
398
+ """Reviewer-facing candidate text and its content identity."""
399
+
400
+ subject: PresentationSubject
401
+ candidate_text: str
402
+ public_interaction_context_json: str = "{}"
403
+
404
+ def __post_init__(self) -> None:
405
+ if not self.candidate_text.strip():
406
+ raise ValueError("presentation draft candidate text must be non-empty")
407
+ if _sha256(self.candidate_text) != self.subject.text_sha256:
408
+ raise ValueError("presentation draft candidate text does not match its subject")
409
+ public_interaction_context = strict_json_loads(self.public_interaction_context_json)
410
+ if not isinstance(public_interaction_context, dict):
411
+ raise ValueError("presentation public interaction context must be a JSON object")
412
+ if _canonical_json(public_interaction_context) != self.public_interaction_context_json:
413
+ raise ValueError("presentation public interaction context must be canonical JSON")
414
+
415
+ def to_dict(self) -> dict[str, object]:
416
+ return {
417
+ "subject": self.subject.to_dict(),
418
+ "candidate_text": self.candidate_text,
419
+ "public_interaction_context": strict_json_loads(self.public_interaction_context_json),
420
+ "criteria": [
421
+ {
422
+ "criterion_identifier": criterion.identifier,
423
+ "question": criterion.question,
424
+ "decision": None,
425
+ "rationale": "",
426
+ }
427
+ for criterion in _criteria_for_kind(self.subject.kind)
428
+ ],
429
+ }
430
+
431
+
432
+ @dataclass(frozen=True)
433
+ class PresentationReviewDraft:
434
+ """Deterministic reviewer packet containing candidate prose but no private oracle."""
435
+
436
+ benchmark_evidence_digest: str
437
+ subjects: tuple[PresentationReviewDraftSubject, ...]
438
+ format_identifier: str = PRESENTATION_REVIEW_DRAFT_FORMAT
439
+ public_tasks: tuple[PresentationReviewTaskContext, ...] = ()
440
+
441
+ def __post_init__(self) -> None:
442
+ if self.format_identifier != PRESENTATION_REVIEW_DRAFT_FORMAT:
443
+ raise ValueError("presentation review draft format is unsupported")
444
+ if not _DIGEST_PATTERN.fullmatch(self.benchmark_evidence_digest):
445
+ raise ValueError("presentation review draft evidence digest is invalid")
446
+ subjects = tuple(sorted(self.subjects, key=lambda item: item.subject.identity))
447
+ if not subjects:
448
+ raise ValueError("presentation review draft requires subjects")
449
+ identities = tuple(subject.subject.identity for subject in subjects)
450
+ if len(set(identities)) != len(identities):
451
+ raise ValueError("presentation review draft subjects must be unique")
452
+ public_tasks = tuple(
453
+ sorted(self.public_tasks, key=lambda public_task: public_task.task_identifier)
454
+ )
455
+ task_identifiers = tuple(public_task.task_identifier for public_task in public_tasks)
456
+ if len(set(task_identifiers)) != len(task_identifiers):
457
+ raise ValueError("presentation review draft task contexts must be unique")
458
+ if public_tasks and set(task_identifiers) != {
459
+ subject.subject.case_identifier for subject in subjects
460
+ }:
461
+ raise ValueError("presentation review draft tasks do not match its subjects")
462
+ object.__setattr__(self, "subjects", subjects)
463
+ object.__setattr__(self, "public_tasks", public_tasks)
464
+
465
+ def to_dict(self) -> dict[str, object]:
466
+ return {
467
+ "format": self.format_identifier,
468
+ "benchmark_evidence_digest": self.benchmark_evidence_digest,
469
+ "rubric": {
470
+ **presentation_rubric(),
471
+ "digest": PRESENTATION_RUBRIC_DIGEST,
472
+ },
473
+ "public_tasks": [public_task.to_dict() for public_task in self.public_tasks],
474
+ "reviewer_identifier": "",
475
+ "assessments": [subject.to_dict() for subject in self.subjects],
476
+ }
477
+
478
+ def to_json(self) -> str:
479
+ return _canonical_json(self.to_dict())
480
+
481
+
482
+ def _subject(
483
+ *,
484
+ case_identifier: str,
485
+ correction_round: int,
486
+ source_sha256: str,
487
+ kind: PresentationSubjectKind,
488
+ pointer: str,
489
+ candidate_text: str,
490
+ public_interaction_context: Mapping[str, object],
491
+ ) -> PresentationReviewDraftSubject:
492
+ return PresentationReviewDraftSubject(
493
+ subject=PresentationSubject(
494
+ case_identifier=case_identifier,
495
+ correction_round=correction_round,
496
+ source_sha256=source_sha256,
497
+ kind=kind,
498
+ pointer=pointer,
499
+ text_sha256=_sha256(candidate_text),
500
+ ),
501
+ candidate_text=candidate_text,
502
+ public_interaction_context_json=_canonical_json(public_interaction_context),
503
+ )
504
+
505
+
506
+ def _prompt_subject(
507
+ *,
508
+ case_identifier: str,
509
+ correction_round: int,
510
+ source_sha256: str,
511
+ prompt_contract: object,
512
+ kind: Literal["path_prompt", "confirm_prompt"],
513
+ pointer: str,
514
+ public_interaction_context: Mapping[str, object],
515
+ ) -> PresentationReviewDraftSubject | None:
516
+ if not isinstance(prompt_contract, Mapping) or prompt_contract.get("verbatim") is True:
517
+ return None
518
+ candidate_text = prompt_contract.get("text")
519
+ if not isinstance(candidate_text, str) or not candidate_text.strip():
520
+ raise ValueError(f"candidate prompt at {pointer!r} must contain non-empty text")
521
+ return _subject(
522
+ case_identifier=case_identifier,
523
+ correction_round=correction_round,
524
+ source_sha256=source_sha256,
525
+ kind=kind,
526
+ pointer=pointer,
527
+ candidate_text=candidate_text,
528
+ public_interaction_context=public_interaction_context,
529
+ )
530
+
531
+
532
+ def _referenced_input_context(
533
+ source_document: Mapping[str, object],
534
+ interaction_contract: Mapping[str, object],
535
+ ) -> dict[str, object]:
536
+ slot_identifier = interaction_contract.get("slot")
537
+ if not isinstance(slot_identifier, str):
538
+ confirmation_identifier = interaction_contract.get("confirm")
539
+ slot_identifier = (
540
+ confirmation_identifier if isinstance(confirmation_identifier, str) else None
541
+ )
542
+ if slot_identifier is None:
543
+ return {}
544
+ slots = source_document.get("slots")
545
+ if not isinstance(slots, Mapping):
546
+ return {}
547
+ slot_contract = slots.get(slot_identifier)
548
+ if not isinstance(slot_contract, Mapping):
549
+ return {}
550
+ referenced_context: dict[str, object] = {
551
+ "referenced_slot": {
552
+ "identifier": slot_identifier,
553
+ "contract": strict_json_loads(_canonical_json(slot_contract)),
554
+ }
555
+ }
556
+ domain_identifier = slot_contract.get("domain")
557
+ domains = source_document.get("domains")
558
+ if isinstance(domain_identifier, str) and isinstance(domains, Mapping):
559
+ domain_contract = domains.get(domain_identifier)
560
+ if isinstance(domain_contract, Mapping):
561
+ referenced_context["referenced_domain"] = {
562
+ "identifier": domain_identifier,
563
+ "contract": strict_json_loads(_canonical_json(domain_contract)),
564
+ }
565
+ return referenced_context
566
+
567
+
568
+ def _path_interaction_context(
569
+ source_document: Mapping[str, object],
570
+ path_step: Mapping[str, object],
571
+ ) -> dict[str, object]:
572
+ public_path_step = {
573
+ property_name: strict_json_loads(_canonical_json(property_value))
574
+ for property_name, property_value in path_step.items()
575
+ if property_name not in {"prompt", "step"}
576
+ }
577
+ return {
578
+ "kind": "path_prompt",
579
+ "path_step": public_path_step,
580
+ **_referenced_input_context(source_document, path_step),
581
+ }
582
+
583
+
584
+ def _confirm_interaction_context(
585
+ source_document: Mapping[str, object],
586
+ confirm_contract: Mapping[str, object],
587
+ ) -> dict[str, object]:
588
+ public_confirm_contract = {
589
+ property_name: strict_json_loads(_canonical_json(property_value))
590
+ for property_name, property_value in confirm_contract.items()
591
+ if property_name != "prompt"
592
+ }
593
+ return {
594
+ "kind": "confirm_prompt",
595
+ "confirm_contract": public_confirm_contract,
596
+ **_referenced_input_context(source_document, confirm_contract),
597
+ }
598
+
599
+
600
+ def _draft_subjects_for_source(
601
+ *,
602
+ case_identifier: str,
603
+ correction_round: int,
604
+ source_sha256: str,
605
+ source_document: Mapping[str, object],
606
+ ) -> tuple[PresentationReviewDraftSubject, ...]:
607
+ route_contract = source_document.get("route")
608
+ route_description = (
609
+ route_contract.get("description") if isinstance(route_contract, Mapping) else None
610
+ )
611
+ if not isinstance(route_description, str) or not route_description.strip():
612
+ raise ValueError("successful presentation source requires a route description")
613
+ draft_subjects = [
614
+ _subject(
615
+ case_identifier=case_identifier,
616
+ correction_round=correction_round,
617
+ source_sha256=source_sha256,
618
+ kind="route_description",
619
+ pointer="/route/description",
620
+ candidate_text=route_description,
621
+ public_interaction_context={"kind": "route_description"},
622
+ )
623
+ ]
624
+
625
+ path_contract = source_document.get("path")
626
+ if not isinstance(path_contract, list):
627
+ raise ValueError("successful presentation source requires an ordered path")
628
+ for path_index, path_step in enumerate(path_contract):
629
+ if not isinstance(path_step, Mapping):
630
+ continue
631
+ if prompt_subject := _prompt_subject(
632
+ case_identifier=case_identifier,
633
+ correction_round=correction_round,
634
+ source_sha256=source_sha256,
635
+ prompt_contract=path_step.get("prompt"),
636
+ kind="path_prompt",
637
+ pointer=f"/path/{path_index}/prompt/text",
638
+ public_interaction_context=_path_interaction_context(
639
+ source_document,
640
+ path_step,
641
+ ),
642
+ ):
643
+ draft_subjects.append(prompt_subject)
644
+
645
+ confirm_contract = source_document.get("confirm")
646
+ if isinstance(confirm_contract, Mapping):
647
+ if confirm_subject := _prompt_subject(
648
+ case_identifier=case_identifier,
649
+ correction_round=correction_round,
650
+ source_sha256=source_sha256,
651
+ prompt_contract=confirm_contract.get("prompt"),
652
+ kind="confirm_prompt",
653
+ pointer=f"/{_json_pointer_segment('confirm')}/prompt/text",
654
+ public_interaction_context=_confirm_interaction_context(
655
+ source_document,
656
+ confirm_contract,
657
+ ),
658
+ ):
659
+ draft_subjects.append(confirm_subject)
660
+ return tuple(draft_subjects)
661
+
662
+
663
+ def _evidence_document(
664
+ serialized_evidence: str,
665
+ verified_evidence: AuthoringEvidenceVerification,
666
+ ) -> dict[str, Any]:
667
+ decoded_evidence = strict_json_loads(serialized_evidence)
668
+ if not isinstance(decoded_evidence, dict):
669
+ raise ValueError("verified authoring evidence must be a JSON object")
670
+ evidence_document = cast(dict[str, Any], decoded_evidence)
671
+ if serialized_evidence.strip() != _canonical_json(evidence_document):
672
+ raise ValueError("verified authoring evidence must use canonical compact JSON")
673
+ unsigned_document = dict(evidence_document)
674
+ recorded_digest = unsigned_document.pop("digest", None)
675
+ if recorded_digest != verified_evidence.digest:
676
+ raise ValueError("serialized evidence does not match its verified digest")
677
+ if _sha256(_canonical_json(unsigned_document)) != verified_evidence.digest:
678
+ raise ValueError("serialized evidence content does not match its verified digest")
679
+ return evidence_document
680
+
681
+
682
+ def presentation_review_draft(
683
+ serialized_evidence: str,
684
+ verified_evidence: AuthoringEvidenceVerification,
685
+ ) -> PresentationReviewDraft:
686
+ """Extract every reviewable final-success presentation from verified evidence."""
687
+
688
+ evidence_document = _evidence_document(serialized_evidence, verified_evidence)
689
+ report_document = cast(dict[str, Any], evidence_document["report"])
690
+ case_result_documents = cast(list[dict[str, Any]], report_document["case_results"])
691
+ capture_documents = cast(list[dict[str, Any]], evidence_document["captures"])
692
+ captures_by_key = {
693
+ (capture["case_identifier"], capture["correction_round"]): capture
694
+ for capture in capture_documents
695
+ }
696
+ packaged_cases = {
697
+ benchmark_case.identifier: benchmark_case
698
+ for benchmark_case in load_reference_authoring_corpus().cases
699
+ }
700
+ draft_subjects: list[PresentationReviewDraftSubject] = []
701
+ public_tasks: list[PresentationReviewTaskContext] = []
702
+ successful_cases = 0
703
+ for case_result in case_result_documents:
704
+ attempts = cast(list[dict[str, Any]], case_result["attempts"])
705
+ if not attempts or attempts[-1]["succeeded"] is not True:
706
+ continue
707
+ successful_cases += 1
708
+ case_identifier = cast(str, case_result["case_identifier"])
709
+ benchmark_case = packaged_cases.get(case_identifier)
710
+ if benchmark_case is None:
711
+ raise ValueError("successful presentation case is not in the packaged corpus")
712
+ authoring_task = benchmark_case.authoring_task()
713
+ public_tasks.append(
714
+ PresentationReviewTaskContext(
715
+ task_identifier=authoring_task.identifier,
716
+ prompt=authoring_task.prompt,
717
+ acceptance_json=_canonical_json(authoring_task.acceptance.to_dict()),
718
+ )
719
+ )
720
+ correction_round = cast(int, attempts[-1]["correction_round"])
721
+ capture = captures_by_key.get((case_identifier, correction_round))
722
+ if capture is None:
723
+ raise ValueError("successful presentation case has no aligned final capture")
724
+ authored_source = cast(str, capture["authored_source"])
725
+ source_sha256 = cast(str, capture["source_sha256"])
726
+ if _sha256(authored_source) != source_sha256:
727
+ raise ValueError("presentation source does not match its capture digest")
728
+ source_document = strict_json_loads(authored_source)
729
+ if not isinstance(source_document, dict):
730
+ raise ValueError("successful presentation source must be a JSON object")
731
+ draft_subjects.extend(
732
+ _draft_subjects_for_source(
733
+ case_identifier=case_identifier,
734
+ correction_round=correction_round,
735
+ source_sha256=source_sha256,
736
+ source_document=source_document,
737
+ )
738
+ )
739
+
740
+ if successful_cases != verified_evidence.successful_cases:
741
+ raise ValueError("presentation subjects disagree with the verified successful-case count")
742
+ return PresentationReviewDraft(
743
+ benchmark_evidence_digest=verified_evidence.digest,
744
+ subjects=tuple(draft_subjects),
745
+ public_tasks=tuple(public_tasks),
746
+ )
747
+
748
+
749
+ @dataclass(frozen=True)
750
+ class PresentationReviewVerification:
751
+ """Safe offline summary of a closed review bound to verified evidence."""
752
+
753
+ digest: str
754
+ benchmark_evidence_digest: str
755
+ reviewer_identifier: str
756
+ passed: bool
757
+ subject_count: int
758
+
759
+ def to_dict(self) -> dict[str, object]:
760
+ return {
761
+ "digest": self.digest,
762
+ "benchmark_evidence_digest": self.benchmark_evidence_digest,
763
+ "reviewer_identifier": self.reviewer_identifier,
764
+ "passed": self.passed,
765
+ "subject_count": self.subject_count,
766
+ }
767
+
768
+
769
+ def _require_exact_keys(
770
+ document: Mapping[str, object],
771
+ expected_keys: frozenset[str],
772
+ contract_name: str,
773
+ ) -> None:
774
+ if set(document) != expected_keys:
775
+ raise ValueError(f"{contract_name} does not have the exact required fields")
776
+
777
+
778
+ def finalize_presentation_review_draft(
779
+ serialized_draft: str,
780
+ serialized_evidence: str,
781
+ verified_evidence: AuthoringEvidenceVerification,
782
+ ) -> AuthoringPresentationReview:
783
+ """Finalize human decisions while proving the reviewer packet stayed immutable."""
784
+
785
+ expected_draft = presentation_review_draft(serialized_evidence, verified_evidence)
786
+ expected_document = expected_draft.to_dict()
787
+ decoded_draft = strict_json_loads(serialized_draft)
788
+ if not isinstance(decoded_draft, dict):
789
+ raise ValueError("presentation review draft must be a JSON object")
790
+ draft_document = cast(dict[str, object], decoded_draft)
791
+ _require_exact_keys(
792
+ draft_document,
793
+ frozenset(expected_document),
794
+ "presentation review draft",
795
+ )
796
+ for immutable_field in (
797
+ "format",
798
+ "benchmark_evidence_digest",
799
+ "rubric",
800
+ "public_tasks",
801
+ ):
802
+ if draft_document[immutable_field] != expected_document[immutable_field]:
803
+ raise ValueError(
804
+ f"presentation review draft changed immutable {immutable_field!r} data"
805
+ )
806
+
807
+ reviewer_identifier = draft_document["reviewer_identifier"]
808
+ if not isinstance(reviewer_identifier, str) or not _IDENTIFIER_PATTERN.fullmatch(
809
+ reviewer_identifier
810
+ ):
811
+ raise ValueError("presentation review draft reviewer identifier is invalid")
812
+ edited_assessments = draft_document["assessments"]
813
+ expected_assessments = expected_document["assessments"]
814
+ if not isinstance(edited_assessments, list) or not isinstance(expected_assessments, list):
815
+ raise ValueError("presentation review draft assessments must be arrays")
816
+ if len(edited_assessments) != len(expected_assessments):
817
+ raise ValueError("presentation review draft changed the exact subject set")
818
+
819
+ finalized_assessments: list[PresentationAssessment] = []
820
+ for assessment_index, (edited_assessment, expected_assessment) in enumerate(
821
+ zip(edited_assessments, expected_assessments, strict=True)
822
+ ):
823
+ if not isinstance(edited_assessment, dict) or not isinstance(expected_assessment, dict):
824
+ raise ValueError("presentation review draft assessment must be an object")
825
+ _require_exact_keys(
826
+ edited_assessment,
827
+ frozenset(expected_assessment),
828
+ f"presentation assessment {assessment_index}",
829
+ )
830
+ for immutable_field in (
831
+ "subject",
832
+ "candidate_text",
833
+ "public_interaction_context",
834
+ ):
835
+ if edited_assessment[immutable_field] != expected_assessment[immutable_field]:
836
+ raise ValueError(
837
+ "presentation review draft changed immutable subject or context data"
838
+ )
839
+ edited_criteria = edited_assessment["criteria"]
840
+ expected_criteria = expected_assessment["criteria"]
841
+ if not isinstance(edited_criteria, list) or not isinstance(expected_criteria, list):
842
+ raise ValueError("presentation assessment criteria must be arrays")
843
+ if len(edited_criteria) != len(expected_criteria):
844
+ raise ValueError("presentation assessment changed the exact criterion set")
845
+
846
+ criterion_results: list[PresentationCriterionResult] = []
847
+ for criterion_index, (edited_criterion, expected_criterion) in enumerate(
848
+ zip(edited_criteria, expected_criteria, strict=True)
849
+ ):
850
+ if not isinstance(edited_criterion, dict) or not isinstance(expected_criterion, dict):
851
+ raise ValueError("presentation criterion must be an object")
852
+ _require_exact_keys(
853
+ edited_criterion,
854
+ frozenset(expected_criterion),
855
+ f"presentation criterion {assessment_index}:{criterion_index}",
856
+ )
857
+ for immutable_field in ("criterion_identifier", "question"):
858
+ if edited_criterion[immutable_field] != expected_criterion[immutable_field]:
859
+ raise ValueError(
860
+ "presentation review draft changed immutable rubric question data"
861
+ )
862
+ decision = edited_criterion["decision"]
863
+ rationale = edited_criterion["rationale"]
864
+ if not isinstance(decision, str) or decision not in {"pass", "fail"}:
865
+ raise ValueError("presentation review draft contains a pending decision")
866
+ if not isinstance(rationale, str) or not rationale.strip():
867
+ raise ValueError("presentation review draft criterion rationale is required")
868
+ criterion_results.append(
869
+ PresentationCriterionResult(
870
+ criterion_identifier=cast(str, edited_criterion["criterion_identifier"]),
871
+ decision=cast(PresentationDecision, decision),
872
+ rationale=rationale,
873
+ )
874
+ )
875
+
876
+ expected_subject = cast(dict[str, Any], expected_assessment["subject"])
877
+ finalized_assessments.append(
878
+ PresentationAssessment(
879
+ subject=PresentationSubject(**expected_subject),
880
+ criteria=tuple(criterion_results),
881
+ )
882
+ )
883
+
884
+ return AuthoringPresentationReview(
885
+ benchmark_evidence_digest=verified_evidence.digest,
886
+ reviewer_identifier=reviewer_identifier,
887
+ assessments=tuple(finalized_assessments),
888
+ )
889
+
890
+
891
+ def verify_presentation_review(
892
+ serialized_review: str,
893
+ serialized_evidence: str,
894
+ verified_evidence: AuthoringEvidenceVerification,
895
+ ) -> PresentationReviewVerification:
896
+ """Verify exact subject and criterion closure without network access."""
897
+
898
+ review = AuthoringPresentationReview.from_json(serialized_review)
899
+ if review.benchmark_evidence_digest != verified_evidence.digest:
900
+ raise ValueError("presentation review is bound to different authoring evidence")
901
+ expected_draft = presentation_review_draft(serialized_evidence, verified_evidence)
902
+ expected_subjects = tuple(subject.subject for subject in expected_draft.subjects)
903
+ observed_subjects = tuple(assessment.subject for assessment in review.assessments)
904
+ if observed_subjects != expected_subjects:
905
+ raise ValueError("presentation review subjects do not exactly match verified evidence")
906
+ return PresentationReviewVerification(
907
+ digest=review.digest,
908
+ benchmark_evidence_digest=review.benchmark_evidence_digest,
909
+ reviewer_identifier=review.reviewer_identifier,
910
+ passed=review.passed,
911
+ subject_count=len(review.assessments),
912
+ )
913
+
914
+
915
+ def _review_schema() -> dict[str, Any]:
916
+ criterion_identifiers = [criterion.identifier for criterion in PRESENTATION_RUBRIC_CRITERIA]
917
+ return {
918
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
919
+ "$id": _PRESENTATION_REVIEW_SCHEMA_IDENTIFIER,
920
+ "title": "FlowSpec2 authoring presentation review",
921
+ "type": "object",
922
+ "additionalProperties": False,
923
+ "required": [
924
+ "format",
925
+ "benchmark_evidence_digest",
926
+ "rubric",
927
+ "reviewer_identifier",
928
+ "assessments",
929
+ "passed",
930
+ "digest",
931
+ ],
932
+ "properties": {
933
+ "format": {"const": PRESENTATION_REVIEW_FORMAT},
934
+ "benchmark_evidence_digest": {"$ref": "#/$defs/digest"},
935
+ "rubric": {
936
+ "type": "object",
937
+ "additionalProperties": False,
938
+ "required": ["format", "identifier", "digest"],
939
+ "properties": {
940
+ "format": {"const": PRESENTATION_RUBRIC_FORMAT},
941
+ "identifier": {"const": PRESENTATION_RUBRIC_IDENTIFIER},
942
+ "digest": {"const": PRESENTATION_RUBRIC_DIGEST},
943
+ },
944
+ },
945
+ "reviewer_identifier": {"$ref": "#/$defs/identifier"},
946
+ "assessments": {
947
+ "type": "array",
948
+ "minItems": 1,
949
+ "items": {"$ref": "#/$defs/assessment"},
950
+ },
951
+ "passed": {"type": "boolean"},
952
+ "digest": {"$ref": "#/$defs/digest"},
953
+ },
954
+ "$defs": {
955
+ "identifier": {
956
+ "type": "string",
957
+ "pattern": _IDENTIFIER_PATTERN.pattern,
958
+ },
959
+ "digest": {"type": "string", "pattern": _DIGEST_PATTERN.pattern},
960
+ "subject": {
961
+ "type": "object",
962
+ "additionalProperties": False,
963
+ "required": [
964
+ "case_identifier",
965
+ "correction_round",
966
+ "source_sha256",
967
+ "kind",
968
+ "pointer",
969
+ "text_sha256",
970
+ ],
971
+ "properties": {
972
+ "case_identifier": {"$ref": "#/$defs/identifier"},
973
+ "correction_round": {"type": "integer", "minimum": 0},
974
+ "source_sha256": {"$ref": "#/$defs/digest"},
975
+ "kind": {"enum": ["route_description", "path_prompt", "confirm_prompt"]},
976
+ "pointer": {"type": "string", "pattern": "^/"},
977
+ "text_sha256": {"$ref": "#/$defs/digest"},
978
+ },
979
+ },
980
+ "criterion": {
981
+ "type": "object",
982
+ "additionalProperties": False,
983
+ "required": ["criterion_identifier", "decision", "rationale"],
984
+ "properties": {
985
+ "criterion_identifier": {"enum": criterion_identifiers},
986
+ "decision": {"enum": ["pass", "fail"]},
987
+ "rationale": {"type": "string", "minLength": 1},
988
+ },
989
+ },
990
+ "assessment": {
991
+ "type": "object",
992
+ "additionalProperties": False,
993
+ "required": ["subject", "criteria", "passed"],
994
+ "properties": {
995
+ "subject": {"$ref": "#/$defs/subject"},
996
+ "criteria": {
997
+ "type": "array",
998
+ "minItems": 1,
999
+ "items": {"$ref": "#/$defs/criterion"},
1000
+ },
1001
+ "passed": {"type": "boolean"},
1002
+ },
1003
+ },
1004
+ },
1005
+ }
1006
+
1007
+
1008
+ def presentation_review_schema() -> dict[str, Any]:
1009
+ """Return an owned closed schema for canonical presentation reviews."""
1010
+
1011
+ schema_document = _review_schema()
1012
+ jsonschema.Draft202012Validator.check_schema(schema_document)
1013
+ return cast(dict[str, Any], json.loads(_canonical_json(schema_document)))