playbook-engine 2.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 (105) hide show
  1. playbook_engine/__init__.py +3 -0
  2. playbook_engine/absence.py +538 -0
  3. playbook_engine/agent_judge.py +1325 -0
  4. playbook_engine/agent_segmenter.py +140 -0
  5. playbook_engine/artifact_store.py +314 -0
  6. playbook_engine/cache_format.py +266 -0
  7. playbook_engine/canonicalize.py +142 -0
  8. playbook_engine/citation_resolver.py +274 -0
  9. playbook_engine/clause_aligner.py +860 -0
  10. playbook_engine/clause_classifier.py +900 -0
  11. playbook_engine/clause_differ.py +619 -0
  12. playbook_engine/clause_position_compiler.py +382 -0
  13. playbook_engine/clause_tree.py +428 -0
  14. playbook_engine/cli.py +4266 -0
  15. playbook_engine/config.py +802 -0
  16. playbook_engine/corpus_linter.py +969 -0
  17. playbook_engine/deal_dates.py +814 -0
  18. playbook_engine/deviation_classifier.py +210 -0
  19. playbook_engine/digest.py +854 -0
  20. playbook_engine/document_renderer.py +1134 -0
  21. playbook_engine/docx_ingester.py +659 -0
  22. playbook_engine/docx_normalizer.py +279 -0
  23. playbook_engine/dossiers.py +742 -0
  24. playbook_engine/entity_registry.py +461 -0
  25. playbook_engine/environment.py +226 -0
  26. playbook_engine/equivalence.py +1013 -0
  27. playbook_engine/equivalence_check.py +591 -0
  28. playbook_engine/extraction.py +2561 -0
  29. playbook_engine/floor_candidates.py +1832 -0
  30. playbook_engine/floor_judge.py +328 -0
  31. playbook_engine/index_page_assets.py +695 -0
  32. playbook_engine/induction_version_selector.py +163 -0
  33. playbook_engine/inspection_report.py +483 -0
  34. playbook_engine/instrument.py +178 -0
  35. playbook_engine/intake_plan.py +769 -0
  36. playbook_engine/judgment.py +387 -0
  37. playbook_engine/llm_segmentation_stage.py +296 -0
  38. playbook_engine/llm_segmenter.py +367 -0
  39. playbook_engine/llm_segmenter_batch.py +762 -0
  40. playbook_engine/natural_sort.py +22 -0
  41. playbook_engine/observation_builder.py +2009 -0
  42. playbook_engine/opf_accessors.py +459 -0
  43. playbook_engine/overrides.py +805 -0
  44. playbook_engine/pdf_ingester.py +385 -0
  45. playbook_engine/pipeline.py +6190 -0
  46. playbook_engine/playbook_assembler.py +633 -0
  47. playbook_engine/posture.py +837 -0
  48. playbook_engine/precedent.py +669 -0
  49. playbook_engine/prior_form_origin_check.py +270 -0
  50. playbook_engine/prior_reconstruction.py +1015 -0
  51. playbook_engine/prior_standards.py +218 -0
  52. playbook_engine/provenance_detector.py +523 -0
  53. playbook_engine/resegmentation.py +1312 -0
  54. playbook_engine/reversal_detector.py +304 -0
  55. playbook_engine/review_rows.py +320 -0
  56. playbook_engine/rtf_ingester.py +248 -0
  57. playbook_engine/rubric.py +532 -0
  58. playbook_engine/run_manifest.py +1262 -0
  59. playbook_engine/scope_gate.py +392 -0
  60. playbook_engine/scorecard.py +1444 -0
  61. playbook_engine/segmentation_grounding.py +258 -0
  62. playbook_engine/segmentation_qa.py +581 -0
  63. playbook_engine/segmenter.py +330 -0
  64. playbook_engine/signed_detector.py +778 -0
  65. playbook_engine/staging.py +933 -0
  66. playbook_engine/subsplit.py +603 -0
  67. playbook_engine/subsplit_check.py +323 -0
  68. playbook_engine/taxonomy.py +208 -0
  69. playbook_engine/taxonomy_inductor.py +529 -0
  70. playbook_engine/template_standards.py +221 -0
  71. playbook_engine/toaster_install.py +40 -0
  72. playbook_engine/tracked_changes_overlay.py +363 -0
  73. playbook_engine/validator.py +2057 -0
  74. playbook_engine/version_orderer.py +874 -0
  75. playbook_engine-2.0.0.dist-info/METADATA +322 -0
  76. playbook_engine-2.0.0.dist-info/RECORD +105 -0
  77. playbook_engine-2.0.0.dist-info/WHEEL +4 -0
  78. playbook_engine-2.0.0.dist-info/entry_points.txt +3 -0
  79. playbook_engine-2.0.0.dist-info/licenses/LICENSE +224 -0
  80. playbook_engine-2.0.0.dist-info/licenses/NOTICE +15 -0
  81. spec/CHANGELOG.md +755 -0
  82. spec/clause-tree.schema.json +86 -0
  83. spec/conformance/0.6/README.md +44 -0
  84. spec/conformance/0.6/manifest.json +85 -0
  85. spec/conformance/0.6/vectors/001-minimal-no-perspective.json +104 -0
  86. spec/conformance/0.6/vectors/002-variants-refused-and-exclusions.json +682 -0
  87. spec/conformance/0.6/vectors/003-cap-totals-and-summary.json +638 -0
  88. spec/conformance/0.6/vectors/004-party-alias-grouping.json +542 -0
  89. spec/conformance/0.6/vectors/005-opening-evidence.json +621 -0
  90. spec/conformance/0.6/vectors/006-opening-rules.json +849 -0
  91. spec/conformance/0.6/vectors/007-equivalence-label-and-coverage.json +1035 -0
  92. spec/conformance/0.6/vectors/008-hard-rules-and-dossiers.json +1791 -0
  93. spec/conformance/0.6/vectors/009-standard-parts-and-owner-label.json +788 -0
  94. spec/conformance/0.6/vectors/010-signing-dates.json +646 -0
  95. spec/conformance/0.6/vectors/011-settled-outcomes.json +1117 -0
  96. spec/conformance/0.6/vectors/012-prior-standards.json +943 -0
  97. spec/conformance/0.6/vectors/013-reconstructed-prior-form.json +568 -0
  98. spec/conformance/0.6/vectors/014-verified-absence.json +763 -0
  99. spec/conformance/0.6/vectors/015-number-and-key-rules.json +114 -0
  100. spec/conformance/README.md +64 -0
  101. spec/playbook.schema-0.6.json +3208 -0
  102. spec/taxonomy/affiliation-agreement.yaml +62 -0
  103. spec/taxonomy/cuad-base.yaml +67 -0
  104. spec/taxonomy/general-commercial.yaml +32 -0
  105. spec/taxonomy/nda.yaml +93 -0
@@ -0,0 +1,3 @@
1
+ """playbook-engine — corpus → OPF playbook compiler."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,538 @@
1
+ """Verified absence counts: "we signed N deals without clause X" (issue #251, OPF 0.6).
2
+
3
+ The toaster reads a new contract that lacks a clause type entirely and asks how
4
+ often we have signed without it. Counting a deal that has no precedent record
5
+ for the clause as "signed without it" is wrong whenever the clause was in the
6
+ deal but never classified (on the private acceptance corpus about one
7
+ observation in six is still unclassified), so a missed classification would read
8
+ as an absence. This module splits every (signed deal, clause type) with no
9
+ signed precedent into two facts, recorded in ``evidence.absence``:
10
+
11
+ ``absent_verified``
12
+ None of the deal's signed-text nodes scores at or above :data:`ABSENCE_GUARD`
13
+ against the clause type, so there is no passage of the signed copy that could
14
+ be the clause and was filed elsewhere or not at all.
15
+ ``possibly_present``
16
+ Otherwise: at least one signed-text node scores at or above the guard (the
17
+ record cites up to :data:`MAX_CANDIDATES` of them, best first, each with the
18
+ clause type the classifier filed it under or ``null``), or nothing could be
19
+ checked (``basis: "unverifiable"``: the deal's executed copy produced no
20
+ readable text or has big passages that extracted as junk, or the clause type
21
+ has no reference text, or the executed copy is a different instrument than the
22
+ negotiated agreement: an amendment, a cover sheet or order form incorporating
23
+ its terms by reference or URL, a form switch; :mod:`playbook_engine.instrument`).
24
+
25
+ The nodes scored are every clause node of the executed copy: the ones the
26
+ classifier left unclassified AND the ones it filed under another clause type
27
+ (an unclassified node that is no clause, ``x_non_clause``, is left out). The
28
+ second kind matters: a clause stated inside a block the classifier filed under
29
+ a neighbouring type (a removal right inside the students' responsibilities) is
30
+ as good as unclassified for the question "is it in this deal?". The price is
31
+ that a neighbouring clause's shared vocabulary can keep a deal out of the verified
32
+ count; that errs towards ``possibly_present``, the safe side.
33
+
34
+ The score is the content-word Jaccard similarity the content-similarity
35
+ classifier uses (issue #235, :func:`~playbook_engine.clause_classifier._content_tokens`),
36
+ the best of the node as a whole and each of its paragraphs, sentences and list
37
+ items (:func:`~playbook_engine.resegmentation.split_units`; a big node that holds
38
+ the clause among others would otherwise dilute it below any threshold), against
39
+ each reference text of the clause type: the parts of our standard and of every
40
+ former form of ours, and the classified exemplars of the clause (every text
41
+ any deal signed, opened with or had refused for it). :data:`ABSENCE_GUARD` is
42
+ well below the assign threshold (:data:`~playbook_engine.clause_classifier.CONTENT_ASSIGN_THRESHOLD`,
43
+ 0.25): a node that is merely *plausibly* the clause keeps a deal out of the
44
+ verified count, while only a node that is confidently the clause is assigned
45
+ to it. A node the classifier assigned to the very clause type but that was too
46
+ short to be precedent (it never reached ``evidence.precedent``) scores 1.0: it
47
+ IS the classifier's finding of the clause.
48
+
49
+ Everything is a fact, never a verdict, and a pure function of the store: two
50
+ compiles of the same store produce byte-identical records. Paper side takes no
51
+ part in it.
52
+
53
+ Agent spot-check. :func:`check_subjects` turns a deterministic sample of the
54
+ verified absences (all of them when there are :data:`CHECK_SAMPLE_MAX` or
55
+ fewer) into ``absence_check`` questions, "is clause type X really absent from
56
+ this signed text?"; the verdicts gate nothing, they measure the guard. A
57
+ confirmed-present item means the guard is too weak (a clause scoring just below
58
+ it was counted as absent): tighten it, that is, lower it, until the checked sample
59
+ has none.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import hashlib
65
+ import json
66
+ import re
67
+ from collections.abc import Iterable, Mapping, Sequence
68
+ from dataclasses import dataclass
69
+ from typing import Any
70
+
71
+ from playbook_engine.canonicalize import canonicalize
72
+ from playbook_engine.clause_classifier import _content_tokens
73
+ from playbook_engine.clause_position_compiler import OPFCitation, _is_degenerate_observation_text
74
+ from playbook_engine.instrument import instrument_flags
75
+ from playbook_engine.observation_builder import Observation
76
+ from playbook_engine.resegmentation import split_units
77
+
78
+ __all__ = [
79
+ "ABSENCE_GUARD",
80
+ "ABSENCE_ID_PREFIX",
81
+ "BASES",
82
+ "BASIS_SCORED",
83
+ "BASIS_UNVERIFIABLE",
84
+ "CHECK_KIND",
85
+ "CHECK_SAMPLE_MAX",
86
+ "MAX_CANDIDATES",
87
+ "SCORE_DECIMALS",
88
+ "STATUSES",
89
+ "STATUS_POSSIBLE",
90
+ "STATUS_VERIFIED",
91
+ "AbsenceSubject",
92
+ "absence_id",
93
+ "absence_records",
94
+ "build_absence",
95
+ "check_subjects",
96
+ "counts_by_clause",
97
+ "signed_deal_ids",
98
+ "validate_check_verdict",
99
+ ]
100
+
101
+ #: The pending-item kind of the agent spot-check (``judge/pending.jsonl``).
102
+ CHECK_KIND = "absence_check"
103
+
104
+ #: A deterministic sample of at most this many verified absences is queued for
105
+ #: the check (all of them when there are this many or fewer).
106
+ CHECK_SAMPLE_MAX = 40
107
+
108
+ #: Every absence id starts with this prefix (OPF-SPEC §3.5.8).
109
+ ABSENCE_ID_PREFIX = "abs."
110
+ _ABSENCE_ID_HEX = 16
111
+
112
+ STATUS_VERIFIED = "absent_verified"
113
+ STATUS_POSSIBLE = "possibly_present"
114
+ STATUSES = (STATUS_VERIFIED, STATUS_POSSIBLE)
115
+
116
+ #: ``basis`` of a record: how the status was reached.
117
+ BASIS_SCORED = "scored"
118
+ BASIS_UNVERIFIABLE = "unverifiable"
119
+ BASES = (BASIS_SCORED, BASIS_UNVERIFIABLE)
120
+
121
+ ABSENCE_GUARD: float = 0.10
122
+ """A signed-text node that scores at least this against a clause type keeps the
123
+ deal out of that clause's verified absences (it becomes ``possibly_present``).
124
+
125
+ Well below the content-similarity assign threshold (0.25,
126
+ ``clause_classifier.CONTENT_ASSIGN_THRESHOLD``, issue #235): assigning a node to
127
+ a clause plants a precedent record, so it needs confidence; declining to *verify
128
+ an absence* only needs plausibility, and a missed classification read as an
129
+ absence is the error this guard exists to prevent. A higher guard
130
+ verifies more absences and a lower one fewer, so the spot-check (``absence_check``)
131
+ is the arbiter: lower it the moment a confirmed-present item turns up, and
132
+ relax it only while the checked sample has none. Scores are rounded to :data:`SCORE_DECIMALS` places
133
+ before they are compared with it, so the number a record shows is the number
134
+ that decided."""
135
+
136
+ UNREADABLE_NODE_CHARS = 200
137
+ """A signed-text node of at least this many characters is *unreadable* when fewer
138
+ than :data:`UNREADABLE_MAX_WORD_SHARE` of its whitespace-separated tokens are
139
+ words (three letters or more): a font-encoding failure that extracts a page as
140
+ columns of digits. A shorter node (a signature line, a table cell) is never
141
+ unreadable."""
142
+
143
+ UNREADABLE_MAX_WORD_SHARE = 0.3
144
+
145
+ UNREADABLE_DEAL_CHARS = 1000
146
+ """A signed copy with at least this many characters of unreadable nodes cannot
147
+ have a clause verified absent from it (the clause may sit in the text that did not
148
+ extract): every record of the deal is ``possibly_present`` with ``basis``
149
+ ``unverifiable``. Measured on the private acceptance corpus: 2 of 43 signed copies
150
+ (5,030 and 19,496 unreadable characters)."""
151
+
152
+ MAX_CANDIDATES = 3
153
+ """A ``possibly_present`` record cites at most this many candidate nodes, best
154
+ score first."""
155
+
156
+ SCORE_DECIMALS = 3
157
+ """Scores are rounded to this many decimal places."""
158
+
159
+ MIN_WINDOW_TOKENS = 3
160
+ """A paragraph, sentence or list item with fewer content words than this is not
161
+ scored on its own (the node as a whole always is)."""
162
+
163
+ _CLASSIFIED_SCORE = 1.0
164
+
165
+
166
+ def _wire_score(score: float) -> int | float:
167
+ """A candidate score as written to the document (OPF-SPEC §3.10 number rule).
168
+
169
+ A whole-number score (the classifier's 1.0, or a Jaccard of identical token sets) is
170
+ written as the integer ``1``: Python would serialize ``1.0`` into the canonical form,
171
+ which a verifier built on a standard JSON parser (one that cannot tell ``1`` from
172
+ ``1.0``) would hash differently."""
173
+ return int(score) if float(score).is_integer() else score
174
+
175
+
176
+ def absence_id(agreement_type_id: str, document_id: str, taxonomy_id: str) -> str:
177
+ """The stable id of one absence record.
178
+
179
+ ``"abs." + sha256(canonical([agreement_type_id, document_id, taxonomy_id]))[:16]``;
180
+ (document_id, taxonomy_id) is unique per record, and the status is a fact
181
+ about it that may change when the corpus is re-mined, so it is not hashed."""
182
+ payload = canonicalize([agreement_type_id, document_id, taxonomy_id])
183
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
184
+ return f"{ABSENCE_ID_PREFIX}{digest[:_ABSENCE_ID_HEX]}"
185
+
186
+
187
+ def signed_deal_ids(
188
+ corpus_documents: Iterable[Mapping[str, Any]], precedent: Iterable[Mapping[str, Any]]
189
+ ) -> list[str]:
190
+ """The in-scope deals with a detected executed copy, sorted.
191
+
192
+ ``corpus.documents[].signed_version`` is authoritative when recorded (the
193
+ digest's ``corpus.n_signed`` rule); a document that records none counts as
194
+ signed when one of its precedent records is signed."""
195
+ signed_by_precedent = {
196
+ p.get("document_id")
197
+ for p in precedent
198
+ if isinstance(p, Mapping) and p.get("signed") is True
199
+ }
200
+ ids: set[str] = set()
201
+ for doc in corpus_documents:
202
+ if not isinstance(doc, Mapping) or not doc.get("in_scope", True):
203
+ continue
204
+ document_id = doc.get("document_id")
205
+ if not isinstance(document_id, str):
206
+ continue
207
+ signed = (
208
+ doc.get("signed_version") is not None
209
+ if "signed_version" in doc
210
+ else document_id in signed_by_precedent
211
+ )
212
+ if signed:
213
+ ids.add(document_id)
214
+ return sorted(ids)
215
+
216
+
217
+ def _is_unreadable(text: str) -> bool:
218
+ """Whether *text* is a big node that extracted as junk (see :data:`UNREADABLE_NODE_CHARS`)."""
219
+ if len(text) < UNREADABLE_NODE_CHARS:
220
+ return False
221
+ tokens = text.split()
222
+ words = sum(1 for t in tokens if _WORD.fullmatch(t.strip(".,;:()[]\"'")))
223
+ return words < UNREADABLE_MAX_WORD_SHARE * len(tokens)
224
+
225
+
226
+ _WORD = re.compile(r"[A-Za-z]{3,}")
227
+
228
+
229
+ def _windows(text: str) -> list[frozenset[str]]:
230
+ """The token sets a node is scored on: the whole node, then each unit of it."""
231
+ whole = _content_tokens(text)
232
+ windows = [whole] if whole else []
233
+ units = split_units(text)
234
+ if len(units) > 1:
235
+ for start, end in units:
236
+ tokens = _content_tokens(text[start:end])
237
+ if len(tokens) >= MIN_WINDOW_TOKENS and tokens != whole:
238
+ windows.append(tokens)
239
+ return windows
240
+
241
+
242
+ def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
243
+ inter = len(a & b)
244
+ return inter / (len(a) + len(b) - inter) if inter else 0.0
245
+
246
+
247
+ def _reference_texts(clause: Mapping[str, Any]) -> list[str]:
248
+ """The standard texts of one evidence clause: our standard's parts and whole,
249
+ and the same for every former form of ours."""
250
+ texts: list[str] = []
251
+ for std in (clause.get("our_standard"), *(clause.get("prior_standards") or [])):
252
+ if not isinstance(std, Mapping):
253
+ continue
254
+ parts = [str(p.get("text") or "") for p in std.get("parts") or [] if isinstance(p, Mapping)]
255
+ texts.extend(parts)
256
+ if isinstance(std.get("text"), str):
257
+ texts.append(std["text"])
258
+ return [t for t in texts if t.strip()]
259
+
260
+
261
+ def _citation(obs: Observation) -> dict[str, Any]:
262
+ return OPFCitation(
263
+ document_id=obs.citation.document_id,
264
+ version=obs.citation.version,
265
+ clause_path=obs.citation.clause_path,
266
+ char_span=obs.citation.char_span,
267
+ ).to_dict()
268
+
269
+
270
+ def build_absence(
271
+ *,
272
+ agreement_type_id: str,
273
+ clauses: Sequence[Mapping[str, Any]],
274
+ precedent: Sequence[Mapping[str, Any]],
275
+ observations: Sequence[Observation],
276
+ corpus_documents: Sequence[Mapping[str, Any]],
277
+ ) -> dict[str, Any]:
278
+ """The ``evidence.absence`` section: ``{guard, records}``.
279
+
280
+ One record per (signed deal, clause type) with no signed precedent, in
281
+ (document_id, taxonomy_id) order. *clauses* are the ``evidence.clauses``
282
+ entries (their standards are the reference texts); *precedent* the
283
+ ``evidence.precedent`` records (what the deals signed, and the exemplars'
284
+ signed texts); *observations* the store's rows (the signed-text nodes).
285
+ """
286
+ tids = [c["taxonomy_id"] for c in clauses if isinstance(c.get("taxonomy_id"), str)]
287
+ tid_set = set(tids)
288
+ signed_deals = signed_deal_ids(corpus_documents, precedent)
289
+ signed_set = set(signed_deals)
290
+ has_signed_text = {
291
+ (p.get("document_id"), p.get("taxonomy_id"))
292
+ for p in precedent
293
+ if p.get("signed") is True and isinstance(p.get("signed_text"), Mapping)
294
+ }
295
+
296
+ # Reference token sets per clause type, deduplicated.
297
+ references: dict[str, set[frozenset[str]]] = {tid: set() for tid in tids}
298
+ for clause in clauses:
299
+ tid = clause.get("taxonomy_id")
300
+ if tid not in references:
301
+ continue
302
+ for text in _reference_texts(clause):
303
+ tokens = _content_tokens(text)
304
+ if tokens:
305
+ references[tid].add(tokens)
306
+ for p in precedent:
307
+ p_tid = p.get("taxonomy_id")
308
+ if p_tid not in references:
309
+ continue
310
+ for entry in (p.get("signed_text"), p.get("opening_text"), *(p.get("refused_asks") or [])):
311
+ entry_text = entry.get("text") if isinstance(entry, Mapping) else None
312
+ entry_tokens = (
313
+ _content_tokens(entry_text) if isinstance(entry_text, str) else frozenset()
314
+ )
315
+ if entry_tokens:
316
+ references[p_tid].add(entry_tokens)
317
+ flat_refs = [(tid, ref) for tid in tids for ref in references[tid]]
318
+
319
+ # The signed-text nodes of each signed deal: every row of the executed copy
320
+ # that is a clause (an unclassified node that is no clause is left out).
321
+ nodes: dict[str, list[Observation]] = {d: [] for d in signed_deals}
322
+ unreadable_chars: dict[str, int] = dict.fromkeys(signed_deals, 0)
323
+ for obs in observations:
324
+ document_id = obs.citation.document_id
325
+ if document_id not in signed_set or obs.outcome != "signed":
326
+ continue
327
+ if _is_unreadable(obs.full_text):
328
+ unreadable_chars[document_id] += len(obs.full_text)
329
+ if obs.non_clause is None and obs.full_text.strip():
330
+ nodes[document_id].append(obs)
331
+
332
+ # A signed copy that is a different instrument than the negotiated agreement
333
+ # (an amendment, a cover sheet over incorporated terms, a form switch) cannot
334
+ # verify an absence either: the terms live in another document.
335
+ other_instrument = set(instrument_flags(observations))
336
+
337
+ # A signed copy with big unreadable passages cannot be verified at all.
338
+ unreadable_deals = {
339
+ document_id
340
+ for document_id, chars in unreadable_chars.items()
341
+ if chars >= UNREADABLE_DEAL_CHARS
342
+ }
343
+
344
+ # Score each distinct node text against every clause type once (boilerplate
345
+ # repeats across deals).
346
+ scores_by_text: dict[str, dict[str, float]] = {}
347
+
348
+ def scores_of(text: str) -> dict[str, float]:
349
+ cached = scores_by_text.get(text)
350
+ if cached is None:
351
+ best: dict[str, float] = {}
352
+ for window in _windows(text):
353
+ for tid, ref in flat_refs:
354
+ s = _jaccard(window, ref)
355
+ if s > best.get(tid, 0.0):
356
+ best[tid] = s
357
+ cached = {tid: round(s, SCORE_DECIMALS) for tid, s in best.items()}
358
+ scores_by_text[text] = cached
359
+ return cached
360
+
361
+ records: list[dict[str, Any]] = []
362
+ for document_id in signed_deals:
363
+ for tid in sorted(tid_set):
364
+ if (document_id, tid) in has_signed_text:
365
+ continue
366
+ candidates: list[tuple[float, Observation]] = []
367
+ checked = 0
368
+ for node in nodes[document_id]:
369
+ if node.taxonomy_id == tid:
370
+ # Filed under this very clause but too short to be precedent
371
+ # (a longer one has a precedent record and no absence record).
372
+ if _is_degenerate_observation_text(node.full_text):
373
+ candidates.append((_CLASSIFIED_SCORE, node))
374
+ checked += 1
375
+ continue
376
+ checked += 1
377
+ score = scores_of(node.full_text).get(tid, 0.0)
378
+ if score >= ABSENCE_GUARD:
379
+ candidates.append((score, node))
380
+ candidates.sort(
381
+ key=lambda c: (-c[0], c[1].citation.clause_path, str(c[1].citation.version))
382
+ )
383
+ unverifiable = (
384
+ not nodes[document_id]
385
+ or not references[tid]
386
+ or document_id in unreadable_deals
387
+ or document_id in other_instrument
388
+ )
389
+ records.append(
390
+ {
391
+ "id": absence_id(agreement_type_id, document_id, tid),
392
+ "taxonomy_id": tid,
393
+ "document_id": document_id,
394
+ "status": STATUS_VERIFIED
395
+ if not (candidates or unverifiable)
396
+ else STATUS_POSSIBLE,
397
+ "basis": BASIS_UNVERIFIABLE if unverifiable else BASIS_SCORED,
398
+ "n_checked": checked,
399
+ "candidates": [
400
+ {
401
+ "ref": _citation(node),
402
+ "score": _wire_score(score),
403
+ "filed_under": node.taxonomy_id,
404
+ }
405
+ for score, node in candidates[:MAX_CANDIDATES]
406
+ ],
407
+ }
408
+ )
409
+ return {"guard": ABSENCE_GUARD, "records": records}
410
+
411
+
412
+ def absence_records(playbook: Mapping[str, Any]) -> list[dict[str, Any]]:
413
+ """``evidence.absence.records`` of a document (``[]`` when absent)."""
414
+ evidence = playbook.get("evidence")
415
+ section = evidence.get("absence") if isinstance(evidence, Mapping) else None
416
+ records = section.get("records") if isinstance(section, Mapping) else None
417
+ return [r for r in records if isinstance(r, dict)] if isinstance(records, list) else []
418
+
419
+
420
+ def counts_by_clause(records: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, int]]:
421
+ """``{taxonomy_id: {verified, possible}}`` over *records* (distinct deals)."""
422
+ deals: dict[str, dict[str, set[Any]]] = {}
423
+ for record in records:
424
+ tid = record.get("taxonomy_id")
425
+ if not isinstance(tid, str):
426
+ continue
427
+ bucket = deals.setdefault(tid, {STATUS_VERIFIED: set(), STATUS_POSSIBLE: set()})
428
+ if record.get("status") in bucket:
429
+ bucket[record["status"]].add(record.get("document_id"))
430
+ return {
431
+ tid: {
432
+ "verified": len(b[STATUS_VERIFIED]),
433
+ "possible": len(b[STATUS_POSSIBLE]),
434
+ }
435
+ for tid, b in sorted(deals.items())
436
+ }
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Agent spot-check (absence_check)
441
+ # ---------------------------------------------------------------------------
442
+
443
+
444
+ @dataclass(frozen=True)
445
+ class AbsenceSubject:
446
+ """One ``absence_check`` question; ``payload`` is exactly what the judge sees.
447
+
448
+ The payload carries the clause type, our standard and the deal's whole signed
449
+ text, and no document id, counterparty or paper side. ``absence_id`` names
450
+ the record the question was built from, for the operator's own bookkeeping;
451
+ it is not part of the payload or the key."""
452
+
453
+ key: str
454
+ payload: dict[str, Any]
455
+ absence_id: str
456
+
457
+
458
+ def _payload_key(payload: Any) -> str:
459
+ """The pending-queue key of *payload* (same hash as ``agent_judge._payload_key``)."""
460
+ raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
461
+ return hashlib.sha256(raw.encode()).hexdigest()
462
+
463
+
464
+ def check_subjects(
465
+ *,
466
+ clauses: Sequence[Mapping[str, Any]],
467
+ records: Iterable[Mapping[str, Any]],
468
+ observations: Sequence[Observation],
469
+ limit: int = CHECK_SAMPLE_MAX,
470
+ ) -> list[AbsenceSubject]:
471
+ """The ``absence_check`` sample of one compile.
472
+
473
+ Every ``absent_verified`` record becomes a question (the clause type, our
474
+ standard, and the deal's signed text as an ordered list of its nodes, each
475
+ with the clause-type label the classifier gave it or ``null``); duplicates
476
+ (the same question for two deals with the same signed text) are asked once,
477
+ and the *limit* questions with the lowest payload hash are kept, all of them
478
+ when there are *limit* or fewer. A pure function of the compile: the sample is
479
+ the same on every run over the same store.
480
+ """
481
+ records = list(records)
482
+ clause_by_tid = {c["taxonomy_id"]: c for c in clauses if isinstance(c.get("taxonomy_id"), str)}
483
+ label_of = {tid: str(c.get("title") or tid) for tid, c in clause_by_tid.items()}
484
+ nodes_by_doc: dict[str, list[dict[str, Any]]] = {}
485
+ wanted = {r.get("document_id") for r in records if r.get("status") == STATUS_VERIFIED}
486
+ for obs in observations:
487
+ document_id = obs.citation.document_id
488
+ if document_id not in wanted or obs.outcome != "signed" or not obs.full_text.strip():
489
+ continue
490
+ nodes_by_doc.setdefault(document_id, []).append(
491
+ {
492
+ "clause_type": label_of.get(obs.taxonomy_id, obs.taxonomy_id)
493
+ if obs.taxonomy_id
494
+ else None,
495
+ "text": obs.full_text,
496
+ }
497
+ )
498
+ questions: dict[str, AbsenceSubject] = {}
499
+ for record in records:
500
+ if record.get("status") != STATUS_VERIFIED:
501
+ continue
502
+ rec_tid, rec_doc = record.get("taxonomy_id"), record.get("document_id")
503
+ clause = clause_by_tid.get(rec_tid) if isinstance(rec_tid, str) else None
504
+ if clause is None or not isinstance(rec_doc, str):
505
+ continue
506
+ std = clause.get("our_standard")
507
+ payload = {
508
+ "stage": CHECK_KIND,
509
+ "question": (
510
+ "Is the clause type below really absent from this signed text, "
511
+ "that is, does no passage of it state this clause?"
512
+ ),
513
+ "clause_type": {"id": str(rec_tid), "label": label_of[str(rec_tid)]},
514
+ "our_standard": std.get("text") if isinstance(std, Mapping) else None,
515
+ "signed_text": nodes_by_doc.get(rec_doc, []),
516
+ }
517
+ key = _payload_key(payload)
518
+ questions.setdefault(
519
+ key, AbsenceSubject(key=key, payload=payload, absence_id=str(record.get("id")))
520
+ )
521
+ keys = sorted(questions)[: max(0, limit)]
522
+ return [questions[k] for k in keys]
523
+
524
+
525
+ def validate_check_verdict(verdict: Mapping[str, Any]) -> None:
526
+ """Check a producer-supplied ``absence_check`` verdict.
527
+
528
+ ``{"absent": true | false, "reason": "<optional short text>"}`` -- ``true``
529
+ when the clause type is really absent from the signed text, ``false`` when a
530
+ passage of it states the clause (a confirmed-present item: the guard is too
531
+ high).
532
+ """
533
+ absent = verdict.get("absent")
534
+ if not isinstance(absent, bool):
535
+ raise ValueError("'absent' must be a JSON boolean")
536
+ reason = verdict.get("reason")
537
+ if reason is not None and not isinstance(reason, str):
538
+ raise ValueError("'reason' must be a string when given")