postmo 1.0.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {postmo-1.0.0/src/postmo.egg-info → postmo-1.2.0}/PKG-INFO +1 -1
- {postmo-1.0.0 → postmo-1.2.0}/pyproject.toml +1 -1
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/__init__.py +10 -8
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/record.py +32 -1
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/scans.py +235 -20
- {postmo-1.0.0 → postmo-1.2.0/src/postmo.egg-info}/PKG-INFO +1 -1
- {postmo-1.0.0 → postmo-1.2.0}/tests/test_record.py +37 -0
- postmo-1.2.0/tests/test_scans.py +477 -0
- postmo-1.0.0/tests/test_scans.py +0 -198
- {postmo-1.0.0 → postmo-1.2.0}/LICENSE +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/README.md +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/setup.cfg +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/disciplines.py +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/evidence.py +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/ledger.py +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo/overlay.py +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo.egg-info/SOURCES.txt +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo.egg-info/dependency_links.txt +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/src/postmo.egg-info/top_level.txt +0 -0
- {postmo-1.0.0 → postmo-1.2.0}/tests/test_ledger.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postmo"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Hold a messy multi-person situation without resolving it: five audit disciplines as linter checks plus a full structured audit record."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -28,10 +28,11 @@ from .record import (
|
|
|
28
28
|
utc_now,
|
|
29
29
|
)
|
|
30
30
|
from .scans import (
|
|
31
|
-
Correction, CorrectionCandidate,
|
|
32
|
-
ScanError, ScanReport, SofteningFlag,
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
Correction, CorrectionCandidate, CorrectionCoverage, HeldTension,
|
|
32
|
+
MotiveFlag, PersonState, ScanError, ScanReport, SofteningFlag,
|
|
33
|
+
SofteningResult, VerdictWordHit, correction_coverage, detect_softening,
|
|
34
|
+
detect_supplied_motive, find_correction_candidates, find_subordinators,
|
|
35
|
+
find_verdict_words, render_correction_coverage, scan_text,
|
|
35
36
|
)
|
|
36
37
|
|
|
37
38
|
__all__ = [
|
|
@@ -41,9 +42,10 @@ __all__ = [
|
|
|
41
42
|
"NEVER", "attachment_for", "describe_contract",
|
|
42
43
|
"PROTOCOL", "AuditRecord", "DisciplineEntry", "RecordError",
|
|
43
44
|
"check_stand_down", "parse", "utc_now",
|
|
44
|
-
"Correction", "CorrectionCandidate", "
|
|
45
|
-
"
|
|
46
|
-
"
|
|
45
|
+
"Correction", "CorrectionCandidate", "CorrectionCoverage",
|
|
46
|
+
"HeldTension", "MotiveFlag", "PersonState", "ScanError", "ScanReport",
|
|
47
|
+
"SofteningFlag", "SofteningResult", "VerdictWordHit",
|
|
48
|
+
"correction_coverage", "detect_softening", "detect_supplied_motive",
|
|
47
49
|
"find_correction_candidates", "find_subordinators", "find_verdict_words",
|
|
48
|
-
"scan_text",
|
|
50
|
+
"render_correction_coverage", "scan_text",
|
|
49
51
|
]
|
|
@@ -238,19 +238,50 @@ def _section_text(rendered: str, key: str) -> str:
|
|
|
238
238
|
if not match:
|
|
239
239
|
return ""
|
|
240
240
|
tail = rendered[match.end():]
|
|
241
|
+
# Consume the rest of the heading line (" (discipline 1)" etc.) —
|
|
242
|
+
# it is heading, not section content.
|
|
243
|
+
newline = tail.find("\n")
|
|
244
|
+
tail = tail[newline + 1:] if newline != -1 else ""
|
|
241
245
|
next_heading = re.search(r"^## ", tail, re.MULTILINE)
|
|
242
246
|
return tail[: next_heading.start()] if next_heading else tail
|
|
243
247
|
|
|
244
248
|
|
|
249
|
+
def _deviations(rendered: str) -> list[str]:
|
|
250
|
+
"""Non-schema lines inside record sections.
|
|
251
|
+
|
|
252
|
+
PARAM-POSTMO-013: a line inside a section that is neither blank nor
|
|
253
|
+
a "- " schema item is a deviation — the canonical archive must not
|
|
254
|
+
eat its own marginalia. Returns "section: line N: 'content'" strings.
|
|
255
|
+
"""
|
|
256
|
+
found: list[str] = []
|
|
257
|
+
for key in _SECTION_RES:
|
|
258
|
+
for lineno, line in enumerate(
|
|
259
|
+
_section_text(rendered, key).splitlines(), 1):
|
|
260
|
+
if line.strip() and not line.startswith("- "):
|
|
261
|
+
found.append(f"{key}: line {lineno}: {line.strip()!r}")
|
|
262
|
+
return found
|
|
263
|
+
|
|
264
|
+
|
|
245
265
|
def parse(rendered: str) -> AuditRecord:
|
|
246
266
|
"""Parse a rendered record back into an AuditRecord.
|
|
247
267
|
|
|
248
268
|
Round-trip fidelity: case name, lifecycle, date, and every
|
|
249
269
|
structured section survive. Items re-validate on the way in —
|
|
250
270
|
a hand-edited record with a subordinating tension or a bad tag
|
|
251
|
-
fails here, not silently.
|
|
271
|
+
fails here, not silently. PARAM-POSTMO-013: parse() is loud about
|
|
272
|
+
non-schema lines — a hand annotation inside a section is rejected
|
|
273
|
+
(RecordError naming the line), never silently swallowed.
|
|
252
274
|
"""
|
|
253
275
|
check_stand_down(rendered)
|
|
276
|
+
deviations = _deviations(rendered)
|
|
277
|
+
if deviations:
|
|
278
|
+
raise RecordError(
|
|
279
|
+
"Non-schema lines inside record sections "
|
|
280
|
+
f"({len(deviations)}):\n"
|
|
281
|
+
+ "\n".join(f" - {d}" for d in deviations)
|
|
282
|
+
+ "\nparse() does not swallow marginalia — move hand "
|
|
283
|
+
"annotations to the Evidence section as '- [tag] text' items."
|
|
284
|
+
)
|
|
254
285
|
front = dict(
|
|
255
286
|
line.split(":", 1)
|
|
256
287
|
for line in rendered.splitlines()
|
|
@@ -20,6 +20,7 @@ disagree. See README "Overlap with evasion_audit".
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import re
|
|
23
|
+
from collections.abc import Iterable
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
25
|
|
|
25
26
|
|
|
@@ -183,18 +184,139 @@ class CorrectionCandidate:
|
|
|
183
184
|
marker: str
|
|
184
185
|
|
|
185
186
|
|
|
187
|
+
def _marker_for(sentence: str) -> str:
|
|
188
|
+
"""First correction marker found in the sentence, or "".
|
|
189
|
+
|
|
190
|
+
Shared with find_correction_candidates so the two can't drift.
|
|
191
|
+
"""
|
|
192
|
+
lowered = sentence.lower()
|
|
193
|
+
return next((m for m in CORRECTION_MARKERS if m in lowered), "")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _norm(text: str) -> str:
|
|
197
|
+
"""Match key for operator-typed text: stripped, case-folded."""
|
|
198
|
+
return text.strip().casefold()
|
|
199
|
+
|
|
200
|
+
|
|
186
201
|
def find_correction_candidates(text: str) -> list[CorrectionCandidate]:
|
|
187
202
|
"""Flag correction-like sentences that need typing (D3, operator step)."""
|
|
188
203
|
found: list[CorrectionCandidate] = []
|
|
189
204
|
for sentence in sentences(text):
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
found.append(CorrectionCandidate(sentence=sentence, marker=marker))
|
|
194
|
-
break
|
|
205
|
+
marker = _marker_for(sentence)
|
|
206
|
+
if marker:
|
|
207
|
+
found.append(CorrectionCandidate(sentence=sentence, marker=marker))
|
|
195
208
|
return found
|
|
196
209
|
|
|
197
210
|
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class CorrectionCoverage:
|
|
213
|
+
"""One sentence's typing status, published in the final report.
|
|
214
|
+
|
|
215
|
+
PARAM-POSTMO-002: typing stays human, but the untyped becomes
|
|
216
|
+
visible. status "typed": the operator typed this sentence (kind
|
|
217
|
+
recorded). status "candidate": a marker fired but the operator did
|
|
218
|
+
not type it — the reader sees the choice not to type. status
|
|
219
|
+
"unmarked": no marker fired, so it was never surfaced for typing.
|
|
220
|
+
status "unmatched": a typed Correction whose text matches no
|
|
221
|
+
sentence of the text — surfaced, never silently dropped.
|
|
222
|
+
marker: the correction marker found in the sentence, when any.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
sentence: str
|
|
226
|
+
status: str # "typed" | "candidate" | "unmarked" | "unmatched"
|
|
227
|
+
kind: str = "" # "detail" | "core-claim", when typed or unmatched
|
|
228
|
+
marker: str = "" # the marker that fired, when any
|
|
229
|
+
|
|
230
|
+
def __post_init__(self) -> None:
|
|
231
|
+
if self.status not in ("typed", "candidate", "unmarked", "unmatched"):
|
|
232
|
+
raise ScanError(f"Bad coverage status: {self.status!r}.")
|
|
233
|
+
if self.status in ("typed", "unmatched") \
|
|
234
|
+
and self.kind not in CORRECTION_KINDS:
|
|
235
|
+
raise ScanError(
|
|
236
|
+
f"Typed coverage needs a correction kind, got {self.kind!r}."
|
|
237
|
+
)
|
|
238
|
+
if not self.sentence.strip():
|
|
239
|
+
raise ScanError("Coverage row needs the sentence.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def correction_coverage(
|
|
243
|
+
text: str, typed: Iterable[Correction] = ()
|
|
244
|
+
) -> list[CorrectionCoverage]:
|
|
245
|
+
"""Typing-coverage audit for the final report (D3, PARAM-POSTMO-002).
|
|
246
|
+
|
|
247
|
+
Publishes every sentence with its typing status — marked, unmarked,
|
|
248
|
+
typed, untyped — so readers see what the operator chose not to
|
|
249
|
+
type. `typed` is the operator's typed Corrections; a Correction
|
|
250
|
+
matches a sentence when its text matches the sentence after
|
|
251
|
+
stripping and case-folding. A typed Correction whose text matches no
|
|
252
|
+
sentence is appended with status "unmatched" — surfaced, never
|
|
253
|
+
silently dropped. Duplicate typed Corrections for one sentence:
|
|
254
|
+
the first wins. `typed` may be any iterable (generators included) —
|
|
255
|
+
it is materialized once up front.
|
|
256
|
+
"""
|
|
257
|
+
typed = list(typed)
|
|
258
|
+
typed_by_text: dict[str, str] = {}
|
|
259
|
+
for correction in typed:
|
|
260
|
+
typed_by_text.setdefault(_norm(correction.text), correction.kind)
|
|
261
|
+
rows: list[CorrectionCoverage] = []
|
|
262
|
+
matched: set[str] = set()
|
|
263
|
+
for sentence in sentences(text):
|
|
264
|
+
marker = _marker_for(sentence)
|
|
265
|
+
key = _norm(sentence)
|
|
266
|
+
if key in typed_by_text:
|
|
267
|
+
matched.add(key)
|
|
268
|
+
rows.append(CorrectionCoverage(
|
|
269
|
+
sentence=sentence, status="typed",
|
|
270
|
+
kind=typed_by_text[key], marker=marker,
|
|
271
|
+
))
|
|
272
|
+
elif marker:
|
|
273
|
+
rows.append(CorrectionCoverage(
|
|
274
|
+
sentence=sentence, status="candidate", marker=marker,
|
|
275
|
+
))
|
|
276
|
+
else:
|
|
277
|
+
rows.append(CorrectionCoverage(sentence=sentence, status="unmarked"))
|
|
278
|
+
for correction in typed:
|
|
279
|
+
key = _norm(correction.text)
|
|
280
|
+
if key not in matched:
|
|
281
|
+
matched.add(key)
|
|
282
|
+
rows.append(CorrectionCoverage(
|
|
283
|
+
sentence=correction.text.strip(), status="unmatched",
|
|
284
|
+
kind=correction.kind,
|
|
285
|
+
))
|
|
286
|
+
return rows
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def render_correction_coverage(
|
|
290
|
+
text: str, typed: Iterable[Correction] = ()
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Render the D3 typing-coverage section for the final report.
|
|
293
|
+
|
|
294
|
+
One line per sentence: its typing status, the kind when typed, and
|
|
295
|
+
the marker when one fired. Untyped candidates and unmarked
|
|
296
|
+
sentences are published, not hidden — typing-coverage is auditable.
|
|
297
|
+
Typed corrections matching no sentence get their own section.
|
|
298
|
+
"""
|
|
299
|
+
lines = ["## Correction typing coverage (discipline 3)"]
|
|
300
|
+
unmatched: list[CorrectionCoverage] = []
|
|
301
|
+
for row in correction_coverage(text, typed):
|
|
302
|
+
if row.status == "unmatched":
|
|
303
|
+
unmatched.append(row)
|
|
304
|
+
continue
|
|
305
|
+
marker = f" (marker: {row.marker!r})" if row.marker else ""
|
|
306
|
+
if row.status == "typed":
|
|
307
|
+
lines.append(f"- [typed: {row.kind}]{marker} {row.sentence}")
|
|
308
|
+
elif row.status == "candidate":
|
|
309
|
+
lines.append(f"- [untyped candidate]{marker} {row.sentence}")
|
|
310
|
+
else:
|
|
311
|
+
lines.append(f"- [unmarked] {row.sentence}")
|
|
312
|
+
if unmatched:
|
|
313
|
+
lines.append("")
|
|
314
|
+
lines.append("## Typed corrections not found in the text")
|
|
315
|
+
for row in unmatched:
|
|
316
|
+
lines.append(f"- [unmatched: {row.kind}] {row.sentence}")
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
198
320
|
# --- Discipline 4: supplied-motive detection -----------------------------------
|
|
199
321
|
|
|
200
322
|
# Attributions that make a motive/causal claim legitimate — the teller
|
|
@@ -227,8 +349,31 @@ MOTIVE_PATTERNS = (
|
|
|
227
349
|
)
|
|
228
350
|
|
|
229
351
|
_MOTIVE_RES = tuple(re.compile(p, re.IGNORECASE) for p in MOTIVE_PATTERNS)
|
|
352
|
+
|
|
353
|
+
# Speech verbs that pair with a quote to form attribution context.
|
|
354
|
+
# PARAM-POSTMO-014: a bare double-quote alone is NOT attribution — scare
|
|
355
|
+
# quotes must not pass as sourcing. Quoted material counts as attributed
|
|
356
|
+
# only with a recognizable source: an attribution marker, or a speech
|
|
357
|
+
# verb in a dialogue tag directly beside the quote (", she said" /
|
|
358
|
+
# "she said, "). "Adjacent" is deliberately tight — a verb in another
|
|
359
|
+
# clause ("when the witness said") does not attribute the quote. A miss
|
|
360
|
+
# here fails toward a flag, and a flag is a REVIEW item: the operator
|
|
361
|
+
# decides, which is the safe direction for an audit tripwire.
|
|
362
|
+
_QUOTE_VERBS = (
|
|
363
|
+
"said", "stated", "told", "asked", "replied", "answered",
|
|
364
|
+
"explained", "claimed", "admitted", "insisted", "recalled",
|
|
365
|
+
"described", "reported", "wrote", "shouted", "whispered", "muttered",
|
|
366
|
+
"quotes", "quoted",
|
|
367
|
+
)
|
|
368
|
+
_VERB_ALT = "|".join(re.escape(v) for v in _QUOTE_VERBS)
|
|
369
|
+
# A dialogue-tag speaker: pronoun, "the <noun>", or a capitalized name
|
|
370
|
+
# (possibly multi-word: "Jane Doe"). Case-sensitive on the name so a
|
|
371
|
+
# lowercase clause word ("when the witness admitted") can't pose as one.
|
|
372
|
+
_SPEAKER = r"(?:he|she|they|i|we|you|the\s+\w+|(?-i:[A-Z])[\w.]*(?:\s+(?-i:[A-Z])[\w.]*)*)"
|
|
230
373
|
_ATTRIBUTION_RE = re.compile(
|
|
231
|
-
r"\b(" + "|".join(re.escape(m) for m in ATTRIBUTION_MARKERS) + r")\b
|
|
374
|
+
r"\b(" + "|".join(re.escape(m) for m in ATTRIBUTION_MARKERS) + r")\b"
|
|
375
|
+
r"|\b(" + _VERB_ALT + r")\b(?:\s+(?:him|her|them|me|us|it))?[,\s:]{0,6}\""
|
|
376
|
+
r"|\"[,\s:]{0,6}(?:" + _SPEAKER + r"\s+)?\b(" + _VERB_ALT + r")\b",
|
|
232
377
|
re.IGNORECASE,
|
|
233
378
|
)
|
|
234
379
|
_GUESS_RE = re.compile(
|
|
@@ -263,7 +408,9 @@ def detect_supplied_motive(text: str) -> list[MotiveFlag]:
|
|
|
263
408
|
|
|
264
409
|
"He tackled you because he panicked" → flag. "He said he panicked"
|
|
265
410
|
→ clean (the teller assigned the why). "If I had to guess, he
|
|
266
|
-
panicked" → guess (allowed, kept marked).
|
|
411
|
+
panicked" → guess (allowed, kept marked). Scare quotes are not
|
|
412
|
+
attribution: 'He panicked and "ran"' → flag, while '"He panicked,"
|
|
413
|
+
she admitted' → clean (quote-adjacent speech verb).
|
|
267
414
|
"""
|
|
268
415
|
flags: list[MotiveFlag] = []
|
|
269
416
|
for sentence in sentences(text):
|
|
@@ -358,7 +505,31 @@ def _word_present(word: str, text: str) -> bool:
|
|
|
358
505
|
return re.search(r"\b" + re.escape(word) + r"\b", text, re.IGNORECASE) is not None
|
|
359
506
|
|
|
360
507
|
|
|
361
|
-
|
|
508
|
+
@dataclass(frozen=True)
|
|
509
|
+
class SofteningResult:
|
|
510
|
+
"""Coverage-carrying result of the D5 softening comparison.
|
|
511
|
+
|
|
512
|
+
PARAM-POSTMO-016: a bare flags list cannot encode "no tripwire
|
|
513
|
+
fired" vs "nothing to find" — the result carries its own coverage,
|
|
514
|
+
so an empty flags list is distinguishable from "text is clean" at
|
|
515
|
+
the call site. DOWNGRADE_PAIRS stays a closed 1:1 table (the table
|
|
516
|
+
itself is wont_change); matching semantics untouched.
|
|
517
|
+
|
|
518
|
+
Frozen protects field rebinding, not the flags list's contents —
|
|
519
|
+
treat result.flags as read-only.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
flags: list[SofteningFlag] = field(default_factory=list)
|
|
523
|
+
pairs_consulted: int = len(DOWNGRADE_PAIRS)
|
|
524
|
+
scope_note: str = "closed 1:1 table; off-table synonyms not evaluated"
|
|
525
|
+
# S8-06 P2 (wont_fix_by_design): the D2 verdict-word scan requires a
|
|
526
|
+
# copula before the noun ("he's a liar") — bare nominals ("the
|
|
527
|
+
# manipulator on the team") are not flagged. Disclosed here per the
|
|
528
|
+
# adjudicated contract rather than removed.
|
|
529
|
+
grammar_required: str = "copula"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def detect_softening(original: str, restatement: str) -> SofteningResult:
|
|
362
533
|
"""Compare the teller's words against a restatement (D5).
|
|
363
534
|
|
|
364
535
|
Catches: strong terms downgraded to soft ones, strong terms dropped
|
|
@@ -366,6 +537,10 @@ def detect_softening(original: str, restatement: str) -> list[SofteningFlag]:
|
|
|
366
537
|
phrases in the restatement. What it cannot catch — generous
|
|
367
538
|
interpretations supplied unasked, evasive answers — stays operator
|
|
368
539
|
protocol (see disciplines.py D5).
|
|
540
|
+
|
|
541
|
+
Returns a SofteningResult (PARAM-POSTMO-016), not a bare list — the
|
|
542
|
+
result carries the coverage (pairs consulted, scope note) so the
|
|
543
|
+
call site can tell "no flags" apart from "text is clean".
|
|
369
544
|
"""
|
|
370
545
|
flags: list[SofteningFlag] = []
|
|
371
546
|
for strong, soft in DOWNGRADE_PAIRS:
|
|
@@ -394,7 +569,7 @@ def detect_softening(original: str, restatement: str) -> list[SofteningFlag]:
|
|
|
394
569
|
kind="ambivalence-resolved",
|
|
395
570
|
detail=f"Restatement resolves ambivalence: {ambivalence.group(0)!r}.",
|
|
396
571
|
))
|
|
397
|
-
return flags
|
|
572
|
+
return SofteningResult(flags=flags)
|
|
398
573
|
|
|
399
574
|
|
|
400
575
|
# --- Batch audit: run every mechanical check at once ----------------------------
|
|
@@ -407,27 +582,67 @@ class ScanReport:
|
|
|
407
582
|
verdict_words: list[VerdictWordHit] = field(default_factory=list)
|
|
408
583
|
correction_candidates: list[CorrectionCandidate] = field(default_factory=list)
|
|
409
584
|
motive_flags: list[MotiveFlag] = field(default_factory=list)
|
|
410
|
-
|
|
585
|
+
correction_coverage: list[CorrectionCoverage] = field(default_factory=list)
|
|
586
|
+
# PARAM-POSTMO-016: the D5 softening comparison returns a
|
|
587
|
+
# coverage-carrying SofteningResult. None = D5 not checked (no
|
|
588
|
+
# restatement supplied) — "softening not checked" is not "no
|
|
589
|
+
# softening found" (PARAM-POSTMO-015).
|
|
590
|
+
softening: SofteningResult | None = None
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def softening_flags(self) -> list[SofteningFlag]:
|
|
594
|
+
"""The D5 flags ([] when D5 was not checked). Kept so callers
|
|
595
|
+
iterating flags directly keep working."""
|
|
596
|
+
return self.softening.flags if self.softening is not None else []
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def softening_checked(self) -> bool:
|
|
600
|
+
"""True when the D5 comparison ran (a restatement was supplied)."""
|
|
601
|
+
return self.softening is not None
|
|
411
602
|
|
|
412
603
|
def clean(self) -> bool:
|
|
413
|
-
"""True when
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
604
|
+
"""True when every check ran and none fired.
|
|
605
|
+
|
|
606
|
+
Never true when the D5 softening comparison was not checked —
|
|
607
|
+
an unchecked D5 is not a clean bill of health (PARAM-POSTMO-015).
|
|
608
|
+
See the honesty note at the top of this module.
|
|
609
|
+
"""
|
|
610
|
+
return self.softening is not None and not (
|
|
611
|
+
self.subordinators or self.verdict_words
|
|
612
|
+
or self.correction_candidates or self.motive_flags
|
|
613
|
+
or self.softening.flags
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def softening_state(self) -> str:
|
|
617
|
+
"""The D5 state, explicit: "not-checked" | "clean" | "flagged"."""
|
|
618
|
+
if self.softening is None:
|
|
619
|
+
return "not-checked"
|
|
620
|
+
return "flagged" if self.softening.flags else "clean"
|
|
418
621
|
|
|
419
622
|
|
|
420
|
-
def scan_text(text: str, *, restatement: str = ""
|
|
623
|
+
def scan_text(text: str, *, restatement: str = "",
|
|
624
|
+
typed: Iterable[Correction] = ()) -> ScanReport:
|
|
421
625
|
"""Run every mechanical check over `text`.
|
|
422
626
|
|
|
423
627
|
Pass the auditor's restatement as `restatement` to enable the D5
|
|
424
|
-
softening comparison.
|
|
425
|
-
|
|
628
|
+
softening comparison. Without it the D5 check does not run, the
|
|
629
|
+
report records softening as "not-checked", and clean() is False —
|
|
630
|
+
an unchecked D5 never reports clean (PARAM-POSTMO-015). Pass the
|
|
631
|
+
operator's typed Corrections as `typed` to enable the D3
|
|
632
|
+
typing-coverage report (PARAM-POSTMO-002): typed sentences drop off
|
|
633
|
+
the candidate list, and the report carries every sentence with its
|
|
634
|
+
typing status. Everything that fires is a REVIEW item for the
|
|
635
|
+
operator — the scan finds, the operator decides.
|
|
426
636
|
"""
|
|
637
|
+
typed = list(typed) # generators are one-shot; materialize once
|
|
638
|
+
typed_keys = {_norm(c.text) for c in typed}
|
|
639
|
+
candidates = [c for c in find_correction_candidates(text)
|
|
640
|
+
if _norm(c.sentence) not in typed_keys]
|
|
427
641
|
return ScanReport(
|
|
428
642
|
subordinators=find_subordinators(text),
|
|
429
643
|
verdict_words=find_verdict_words(text),
|
|
430
|
-
correction_candidates=
|
|
644
|
+
correction_candidates=candidates,
|
|
431
645
|
motive_flags=detect_supplied_motive(text),
|
|
432
|
-
|
|
646
|
+
correction_coverage=correction_coverage(text, typed),
|
|
647
|
+
softening=detect_softening(text, restatement) if restatement else None,
|
|
433
648
|
)
|
|
@@ -128,6 +128,43 @@ class RecordRoundTripTest(unittest.TestCase):
|
|
|
128
128
|
twice = parse(once.render())
|
|
129
129
|
self.assertEqual(once.render(), twice.render())
|
|
130
130
|
|
|
131
|
+
def test_parse_rejects_hand_annotation_in_section(self):
|
|
132
|
+
rendered = sample_record().render().replace(
|
|
133
|
+
"## Corrections (discipline 3)\n",
|
|
134
|
+
"## Corrections (discipline 3)\n"
|
|
135
|
+
"(hand note: double-check the Tuesday claim)\n",
|
|
136
|
+
)
|
|
137
|
+
with self.assertRaises(RecordError) as ctx:
|
|
138
|
+
parse(rendered)
|
|
139
|
+
message = str(ctx.exception)
|
|
140
|
+
self.assertIn("Non-schema lines", message)
|
|
141
|
+
self.assertIn("double-check the Tuesday claim", message)
|
|
142
|
+
self.assertIn("Evidence section", message)
|
|
143
|
+
|
|
144
|
+
def test_parse_rejects_multiple_deviations_at_once(self):
|
|
145
|
+
rendered = sample_record().render()
|
|
146
|
+
rendered = rendered.replace(
|
|
147
|
+
"## Held tensions (discipline 1)\n",
|
|
148
|
+
"## Held tensions (discipline 1)\nnote one\n",
|
|
149
|
+
).replace(
|
|
150
|
+
"## Evidence\n",
|
|
151
|
+
"## Evidence\nnote two\n",
|
|
152
|
+
)
|
|
153
|
+
with self.assertRaises(RecordError) as ctx:
|
|
154
|
+
parse(rendered)
|
|
155
|
+
message = str(ctx.exception)
|
|
156
|
+
self.assertIn("(2)", message)
|
|
157
|
+
self.assertIn("note one", message)
|
|
158
|
+
self.assertIn("note two", message)
|
|
159
|
+
|
|
160
|
+
def test_parse_tolerates_blank_lines_in_sections(self):
|
|
161
|
+
rendered = sample_record().render().replace(
|
|
162
|
+
"## Corrections (discipline 3)\n",
|
|
163
|
+
"## Corrections (discipline 3)\n\n \n",
|
|
164
|
+
)
|
|
165
|
+
rec = parse(rendered)
|
|
166
|
+
self.assertEqual(len(rec.corrections), 2)
|
|
167
|
+
|
|
131
168
|
|
|
132
169
|
def rec_tension_texts():
|
|
133
170
|
return [t.text for t in parse(sample_record().render()).tensions]
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Tests for the mechanical linter checks (scans.py) and the discipline table."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from postmo.disciplines import D1, D2, D3, D4, D5, DISCIPLINES, discipline_name
|
|
10
|
+
|
|
11
|
+
AUDIT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
from postmo.scans import (
|
|
13
|
+
DOWNGRADE_PAIRS, Correction, CorrectionCandidate, CorrectionCoverage,
|
|
14
|
+
HeldTension, MotiveFlag, PersonState, ScanError, ScanReport,
|
|
15
|
+
SofteningResult, correction_coverage, detect_softening,
|
|
16
|
+
detect_supplied_motive, find_correction_candidates, find_subordinators,
|
|
17
|
+
find_verdict_words, render_correction_coverage, scan_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DisciplineTableTest(unittest.TestCase):
|
|
22
|
+
def test_five_disciplines_numbered_1_to_5(self):
|
|
23
|
+
self.assertEqual(set(DISCIPLINES), {1, 2, 3, 4, 5})
|
|
24
|
+
|
|
25
|
+
def test_each_discipline_has_code_operator_split(self):
|
|
26
|
+
for n, d in DISCIPLINES.items():
|
|
27
|
+
for key in ("name", "rule", "mechanical", "operator"):
|
|
28
|
+
self.assertTrue(d[key].strip(), f"D{n} missing {key}")
|
|
29
|
+
|
|
30
|
+
def test_discipline_name(self):
|
|
31
|
+
self.assertEqual(discipline_name(D1), "hold-contradictions")
|
|
32
|
+
self.assertEqual(discipline_name(D5), "softening-self-check")
|
|
33
|
+
with self.assertRaises(ValueError):
|
|
34
|
+
discipline_name(6)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SubordinatorScanTest(unittest.TestCase):
|
|
38
|
+
def test_finds_but_and_however(self):
|
|
39
|
+
self.assertIn("but", find_subordinators("X is true, but Y is also true."))
|
|
40
|
+
self.assertIn("however", find_subordinators("X is true. However, Y."))
|
|
41
|
+
|
|
42
|
+
def test_finds_multiword_connectives(self):
|
|
43
|
+
self.assertIn("which means", find_subordinators("X is true, which means Y."))
|
|
44
|
+
self.assertIn("in other words", find_subordinators("X. In other words, Y."))
|
|
45
|
+
|
|
46
|
+
def test_parallel_sentences_pass(self):
|
|
47
|
+
self.assertEqual(find_subordinators("X is true. Y is also true."), [])
|
|
48
|
+
|
|
49
|
+
def test_held_tension_accepts_parallel(self):
|
|
50
|
+
t = HeldTension(text="I adored her. I'm furious at her.")
|
|
51
|
+
self.assertTrue(t.text)
|
|
52
|
+
|
|
53
|
+
def test_held_tension_rejects_subordinator(self):
|
|
54
|
+
with self.assertRaises(ScanError):
|
|
55
|
+
HeldTension(text="I adored her, but I'm furious at her.")
|
|
56
|
+
with self.assertRaises(ScanError):
|
|
57
|
+
HeldTension(text="I adored her. However, I'm furious at her.")
|
|
58
|
+
|
|
59
|
+
def test_held_tension_rejects_empty(self):
|
|
60
|
+
with self.assertRaises(ScanError):
|
|
61
|
+
HeldTension(text=" ")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VerdictWordScanTest(unittest.TestCase):
|
|
65
|
+
def test_flags_permanent_label(self):
|
|
66
|
+
hits = find_verdict_words("He's a liar, that's all there is to it.")
|
|
67
|
+
self.assertEqual(len(hits), 1)
|
|
68
|
+
self.assertEqual(hits[0].word, "liar")
|
|
69
|
+
|
|
70
|
+
def test_state_description_passes(self):
|
|
71
|
+
# "he was lying on Tuesday" is a time-indexed state, not a verdict.
|
|
72
|
+
self.assertEqual(find_verdict_words("He was lying to her on Tuesday."), [])
|
|
73
|
+
|
|
74
|
+
def test_adjective_state_passes(self):
|
|
75
|
+
# Adjectives can legitimately describe a moment — nouns only.
|
|
76
|
+
self.assertEqual(find_verdict_words("The situation was dangerous."), [])
|
|
77
|
+
|
|
78
|
+
def test_person_state_requires_all_fields(self):
|
|
79
|
+
with self.assertRaises(ScanError):
|
|
80
|
+
PersonState(when="", person="Dana", state="calm")
|
|
81
|
+
with self.assertRaises(ScanError):
|
|
82
|
+
PersonState(when="Tue", person=" ", state="calm")
|
|
83
|
+
ok = PersonState(when="2026-09-20", person="Dana", state="calm, cooperative")
|
|
84
|
+
self.assertEqual(ok.person, "Dana")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CorrectionTypingTest(unittest.TestCase):
|
|
88
|
+
def test_accepts_both_kinds(self):
|
|
89
|
+
self.assertEqual(Correction(kind="detail", text="3pm, not 2pm.").kind, "detail")
|
|
90
|
+
self.assertEqual(
|
|
91
|
+
Correction(kind="core-claim", text="She withdrew the claim.").kind,
|
|
92
|
+
"core-claim",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def test_rejects_bad_kind_and_empty(self):
|
|
96
|
+
with self.assertRaises(ScanError):
|
|
97
|
+
Correction(kind="typo", text="whatever")
|
|
98
|
+
with self.assertRaises(ScanError):
|
|
99
|
+
Correction(kind="detail", text=" ")
|
|
100
|
+
|
|
101
|
+
def test_finds_correction_candidates(self):
|
|
102
|
+
found = find_correction_candidates(
|
|
103
|
+
"The meeting was at two. Actually, it was at three. Dana agreed."
|
|
104
|
+
)
|
|
105
|
+
self.assertEqual(len(found), 1)
|
|
106
|
+
self.assertIn("actually", found[0].marker)
|
|
107
|
+
self.assertTrue(isinstance(found[0], CorrectionCandidate))
|
|
108
|
+
|
|
109
|
+
def test_no_candidates_in_clean_text(self):
|
|
110
|
+
self.assertEqual(
|
|
111
|
+
find_correction_candidates("The meeting was at two. Dana agreed."), []
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CorrectionCoverageTest(unittest.TestCase):
|
|
116
|
+
TEXT = ("The meeting was at two. Actually, it was at three. "
|
|
117
|
+
"Dana agreed to the plan.")
|
|
118
|
+
|
|
119
|
+
def test_statuses_typed_candidate_unmarked(self):
|
|
120
|
+
typed = [Correction(kind="detail", text="Actually, it was at three.")]
|
|
121
|
+
rows = correction_coverage(self.TEXT, typed)
|
|
122
|
+
self.assertEqual([r.status for r in rows],
|
|
123
|
+
["unmarked", "typed", "unmarked"])
|
|
124
|
+
self.assertEqual(rows[1].kind, "detail")
|
|
125
|
+
self.assertEqual(rows[1].marker, "actually")
|
|
126
|
+
|
|
127
|
+
def test_candidate_carries_marker_when_untyped(self):
|
|
128
|
+
rows = correction_coverage(self.TEXT)
|
|
129
|
+
cand = rows[1]
|
|
130
|
+
self.assertEqual(cand.status, "candidate")
|
|
131
|
+
self.assertEqual(cand.marker, "actually")
|
|
132
|
+
self.assertEqual(cand.kind, "")
|
|
133
|
+
|
|
134
|
+
def test_typed_matches_on_stripped_text(self):
|
|
135
|
+
typed = [Correction(kind="core-claim",
|
|
136
|
+
text=" Actually, it was at three. ")]
|
|
137
|
+
rows = correction_coverage(self.TEXT, typed)
|
|
138
|
+
self.assertEqual(rows[1].status, "typed")
|
|
139
|
+
self.assertEqual(rows[1].kind, "core-claim")
|
|
140
|
+
|
|
141
|
+
def test_typed_matching_is_case_insensitive(self):
|
|
142
|
+
typed = [Correction(kind="detail",
|
|
143
|
+
text="aCtUaLlY, It WaS aT tHrEe.")]
|
|
144
|
+
rows = correction_coverage(self.TEXT, typed)
|
|
145
|
+
self.assertEqual(rows[1].status, "typed")
|
|
146
|
+
self.assertEqual(rows[1].kind, "detail")
|
|
147
|
+
|
|
148
|
+
def test_first_typed_correction_wins_on_duplicates(self):
|
|
149
|
+
typed = [Correction(kind="detail", text="Actually, it was at three."),
|
|
150
|
+
Correction(kind="core-claim",
|
|
151
|
+
text="ACTUALLY, IT WAS AT THREE.")]
|
|
152
|
+
rows = correction_coverage(self.TEXT, typed)
|
|
153
|
+
self.assertEqual(rows[1].status, "typed")
|
|
154
|
+
self.assertEqual(rows[1].kind, "detail")
|
|
155
|
+
|
|
156
|
+
def test_unmatched_typed_correction_is_surfaced_not_dropped(self):
|
|
157
|
+
typed = [Correction(kind="detail", text="A sentence from nowhere.")]
|
|
158
|
+
rows = correction_coverage(self.TEXT, typed)
|
|
159
|
+
self.assertEqual(len(rows), 4)
|
|
160
|
+
orphan = rows[3]
|
|
161
|
+
self.assertEqual(orphan.status, "unmatched")
|
|
162
|
+
self.assertEqual(orphan.kind, "detail")
|
|
163
|
+
self.assertEqual(orphan.sentence, "A sentence from nowhere.")
|
|
164
|
+
|
|
165
|
+
def test_empty_text_has_no_rows(self):
|
|
166
|
+
self.assertEqual(correction_coverage(" "), [])
|
|
167
|
+
|
|
168
|
+
def test_coverage_row_rejects_bad_status_and_kind(self):
|
|
169
|
+
with self.assertRaises(ScanError):
|
|
170
|
+
CorrectionCoverage(sentence="x.", status="maybe")
|
|
171
|
+
with self.assertRaises(ScanError):
|
|
172
|
+
CorrectionCoverage(sentence="x.", status="typed", kind="typo")
|
|
173
|
+
with self.assertRaises(ScanError):
|
|
174
|
+
CorrectionCoverage(sentence=" ", status="unmarked")
|
|
175
|
+
|
|
176
|
+
def test_render_publishes_every_sentence_with_status(self):
|
|
177
|
+
text = (self.TEXT + " To be clear, the venue changed.")
|
|
178
|
+
typed = [Correction(kind="detail", text="Actually, it was at three."),
|
|
179
|
+
Correction(kind="core-claim",
|
|
180
|
+
text="A sentence from nowhere.")]
|
|
181
|
+
rendered = render_correction_coverage(text, typed)
|
|
182
|
+
self.assertIn("## Correction typing coverage (discipline 3)",
|
|
183
|
+
rendered)
|
|
184
|
+
self.assertIn("- [unmarked] The meeting was at two.", rendered)
|
|
185
|
+
self.assertIn("- [typed: detail] (marker: 'actually') "
|
|
186
|
+
"Actually, it was at three.", rendered)
|
|
187
|
+
self.assertIn("- [untyped candidate] (marker: 'to be clear,') "
|
|
188
|
+
"To be clear, the venue changed.", rendered)
|
|
189
|
+
self.assertIn("- [unmarked] Dana agreed to the plan.", rendered)
|
|
190
|
+
self.assertIn("## Typed corrections not found in the text", rendered)
|
|
191
|
+
self.assertIn("- [unmatched: core-claim] A sentence from nowhere.",
|
|
192
|
+
rendered)
|
|
193
|
+
|
|
194
|
+
def test_scan_text_carries_coverage_and_filters_typed_candidates(self):
|
|
195
|
+
typed = [Correction(kind="detail",
|
|
196
|
+
text="actually, it was at three.")]
|
|
197
|
+
report = scan_text(self.TEXT, typed=typed)
|
|
198
|
+
self.assertEqual([r.status for r in report.correction_coverage],
|
|
199
|
+
["unmarked", "typed", "unmarked"])
|
|
200
|
+
self.assertEqual(report.correction_coverage[1].kind, "detail")
|
|
201
|
+
# The typed sentence drops off the needs-typing candidate list.
|
|
202
|
+
self.assertEqual(report.correction_candidates, [])
|
|
203
|
+
|
|
204
|
+
def test_typed_accepts_a_generator(self):
|
|
205
|
+
def gen():
|
|
206
|
+
yield Correction(kind="detail", text="Actually, it was at three.")
|
|
207
|
+
yield Correction(kind="core-claim",
|
|
208
|
+
text="A sentence from nowhere.")
|
|
209
|
+
report = scan_text(self.TEXT, typed=gen())
|
|
210
|
+
self.assertEqual([r.status for r in report.correction_coverage],
|
|
211
|
+
["unmarked", "typed", "unmarked", "unmatched"])
|
|
212
|
+
self.assertEqual(report.correction_candidates, [])
|
|
213
|
+
|
|
214
|
+
def test_scan_text_without_typed_is_unchanged(self):
|
|
215
|
+
report = scan_text(self.TEXT)
|
|
216
|
+
self.assertEqual(len(report.correction_candidates), 1)
|
|
217
|
+
self.assertEqual(report.correction_candidates[0].marker, "actually")
|
|
218
|
+
self.assertEqual([r.status for r in report.correction_coverage],
|
|
219
|
+
["unmarked", "candidate", "unmarked"])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class MotiveScanTest(unittest.TestCase):
|
|
223
|
+
def test_flags_supplied_motive(self):
|
|
224
|
+
flags = detect_supplied_motive("He tackled you because he panicked.")
|
|
225
|
+
self.assertEqual(len(flags), 1)
|
|
226
|
+
self.assertEqual(flags[0].severity, "flag")
|
|
227
|
+
self.assertTrue(isinstance(flags[0], MotiveFlag))
|
|
228
|
+
|
|
229
|
+
def test_attributed_motive_is_clean(self):
|
|
230
|
+
# The teller assigned the why — discipline 4 satisfied.
|
|
231
|
+
self.assertEqual(
|
|
232
|
+
detect_supplied_motive("He said he panicked when it happened."), []
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def test_bare_quote_is_not_attribution(self):
|
|
236
|
+
# Scare quotes must not pass as sourcing (PARAM-POSTMO-014):
|
|
237
|
+
# the quote alone no longer suppresses the motive flag.
|
|
238
|
+
flags = detect_supplied_motive(
|
|
239
|
+
"He panicked and \"ran\" when it happened.")
|
|
240
|
+
self.assertEqual(len(flags), 1)
|
|
241
|
+
self.assertEqual(flags[0].severity, "flag")
|
|
242
|
+
|
|
243
|
+
def test_quote_adjacent_speech_verb_is_attribution(self):
|
|
244
|
+
self.assertEqual(
|
|
245
|
+
detect_supplied_motive("\"He panicked and ran,\" she admitted."),
|
|
246
|
+
[])
|
|
247
|
+
self.assertEqual(
|
|
248
|
+
detect_supplied_motive("She admitted, \"He panicked and ran.\""),
|
|
249
|
+
[])
|
|
250
|
+
self.assertEqual(
|
|
251
|
+
detect_supplied_motive(
|
|
252
|
+
"\"He panicked and ran,\" the witness admitted."), []
|
|
253
|
+
)
|
|
254
|
+
self.assertEqual(
|
|
255
|
+
detect_supplied_motive("The report quotes him: "
|
|
256
|
+
"\"He panicked and ran.\""),
|
|
257
|
+
[])
|
|
258
|
+
|
|
259
|
+
def test_verb_in_unrelated_clause_is_not_attribution(self):
|
|
260
|
+
# "admitted" belongs to another clause — the scare quote stays
|
|
261
|
+
# unattributed. ("said" would be a marker regardless.)
|
|
262
|
+
flags = detect_supplied_motive(
|
|
263
|
+
"He panicked and \"ran\" when the witness admitted "
|
|
264
|
+
"he was afraid.")
|
|
265
|
+
self.assertEqual(len(flags), 1)
|
|
266
|
+
self.assertEqual(flags[0].severity, "flag")
|
|
267
|
+
|
|
268
|
+
def test_quote_with_marker_still_clean(self):
|
|
269
|
+
self.assertEqual(
|
|
270
|
+
detect_supplied_motive("\"He panicked and ran,\" she said."), []
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def test_marked_guess_is_allowed_but_kept(self):
|
|
274
|
+
flags = detect_supplied_motive("If I had to guess, he panicked.")
|
|
275
|
+
self.assertEqual(len(flags), 1)
|
|
276
|
+
self.assertEqual(flags[0].severity, "guess")
|
|
277
|
+
|
|
278
|
+
def test_flags_driven_by_and_out_of(self):
|
|
279
|
+
self.assertTrue(detect_supplied_motive("It was driven by fear."))
|
|
280
|
+
self.assertTrue(detect_supplied_motive("She did it out of spite."))
|
|
281
|
+
|
|
282
|
+
def test_observed_sequence_passes(self):
|
|
283
|
+
self.assertEqual(
|
|
284
|
+
detect_supplied_motive("He tackled you. You fell. He walked away."), []
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def test_motive_flag_rejects_bad_severity(self):
|
|
288
|
+
with self.assertRaises(ScanError):
|
|
289
|
+
MotiveFlag(sentence="x", pattern="y", severity="verdict")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class SofteningScanTest(unittest.TestCase):
|
|
293
|
+
def test_downgrade_betrayal_to_mistake(self):
|
|
294
|
+
result = detect_softening(
|
|
295
|
+
"It was a betrayal of my trust.", "It was a mistake."
|
|
296
|
+
)
|
|
297
|
+
self.assertIsInstance(result, SofteningResult)
|
|
298
|
+
kinds = [f.kind for f in result.flags]
|
|
299
|
+
self.assertIn("downgrade", kinds)
|
|
300
|
+
down = next(f for f in result.flags if f.kind == "downgrade")
|
|
301
|
+
self.assertEqual(down.original_term, "betrayal")
|
|
302
|
+
self.assertEqual(down.replacement, "mistake")
|
|
303
|
+
|
|
304
|
+
def test_dropped_strong_term(self):
|
|
305
|
+
result = detect_softening("He lied to me.", "It was complicated.")
|
|
306
|
+
self.assertTrue(any(
|
|
307
|
+
f.kind == "dropped" and f.original_term == "lied"
|
|
308
|
+
for f in result.flags
|
|
309
|
+
))
|
|
310
|
+
|
|
311
|
+
def test_strong_term_kept_is_clean(self):
|
|
312
|
+
result = detect_softening("He lied to me.", "He lied, and it hurt.")
|
|
313
|
+
self.assertEqual(result.flags, [])
|
|
314
|
+
|
|
315
|
+
def test_tidy_restatement_phrase(self):
|
|
316
|
+
result = detect_softening(
|
|
317
|
+
"I adored her. I'm furious at her.",
|
|
318
|
+
"So what you're saying is you have mixed feelings.",
|
|
319
|
+
)
|
|
320
|
+
self.assertTrue(any(f.kind == "tidy-restatement"
|
|
321
|
+
for f in result.flags))
|
|
322
|
+
|
|
323
|
+
def test_ambivalence_resolved(self):
|
|
324
|
+
result = detect_softening(
|
|
325
|
+
"I adored her. I'm furious at her.",
|
|
326
|
+
"The real feeling underneath is anger.",
|
|
327
|
+
)
|
|
328
|
+
self.assertTrue(any(f.kind == "ambivalence-resolved"
|
|
329
|
+
for f in result.flags))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SofteningResultTest(unittest.TestCase):
|
|
333
|
+
def test_result_carries_coverage(self):
|
|
334
|
+
result = detect_softening("He lied to me.", "He lied, and it hurt.")
|
|
335
|
+
self.assertEqual(result.pairs_consulted, len(DOWNGRADE_PAIRS))
|
|
336
|
+
self.assertEqual(result.scope_note,
|
|
337
|
+
"closed 1:1 table; off-table synonyms not evaluated")
|
|
338
|
+
self.assertEqual(result.grammar_required, "copula")
|
|
339
|
+
|
|
340
|
+
def test_downgrade_table_stays_closed(self):
|
|
341
|
+
# PARAM-POSTMO-016: the table itself is wont_change — pin the
|
|
342
|
+
# size so an accidental expansion trips loudly.
|
|
343
|
+
self.assertEqual(len(DOWNGRADE_PAIRS), 23)
|
|
344
|
+
self.assertIn(("betrayal", "mistake"), DOWNGRADE_PAIRS)
|
|
345
|
+
|
|
346
|
+
def test_matching_semantics_untouched_exact_flag_sequence(self):
|
|
347
|
+
# Regression parity: the exact flag sequence for a multi-hit
|
|
348
|
+
# input — kinds, terms, and order — is unchanged by the return
|
|
349
|
+
# contract (pairs loop order, then tidy, then ambivalence).
|
|
350
|
+
result = detect_softening(
|
|
351
|
+
"It was a betrayal. He lied to me.",
|
|
352
|
+
"So what you're saying is it was a mistake.",
|
|
353
|
+
)
|
|
354
|
+
self.assertEqual(
|
|
355
|
+
[(f.kind, f.original_term, f.replacement)
|
|
356
|
+
for f in result.flags],
|
|
357
|
+
[("downgrade", "betrayal", "mistake"),
|
|
358
|
+
("dropped", "lied", ""),
|
|
359
|
+
("tidy-restatement", "", "")],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def test_softening_checked_property(self):
|
|
363
|
+
self.assertTrue(scan_text(
|
|
364
|
+
"He arrived at three.", restatement="He arrived.").softening_checked)
|
|
365
|
+
self.assertFalse(
|
|
366
|
+
scan_text("He arrived at three.").softening_checked)
|
|
367
|
+
|
|
368
|
+
def test_report_constructible_with_result_directly(self):
|
|
369
|
+
from postmo.scans import SofteningFlag
|
|
370
|
+
result = SofteningResult(flags=[SofteningFlag(
|
|
371
|
+
kind="dropped", detail="'lied' vanished.")])
|
|
372
|
+
report = ScanReport(softening=result)
|
|
373
|
+
self.assertEqual(report.softening_state(), "flagged")
|
|
374
|
+
self.assertFalse(report.clean())
|
|
375
|
+
self.assertEqual(len(report.softening_flags), 1)
|
|
376
|
+
|
|
377
|
+
def test_empty_flags_distinguishable_from_unchecked(self):
|
|
378
|
+
# D5 ran, no flags: the result exists and is empty — "no tripwire
|
|
379
|
+
# fired", not "text is clean".
|
|
380
|
+
checked = scan_text(
|
|
381
|
+
"He arrived at three.",
|
|
382
|
+
restatement="He arrived at three.",
|
|
383
|
+
)
|
|
384
|
+
self.assertIsNotNone(checked.softening)
|
|
385
|
+
self.assertEqual(checked.softening.flags, [])
|
|
386
|
+
self.assertEqual(checked.softening_state(), "clean")
|
|
387
|
+
self.assertTrue(checked.clean())
|
|
388
|
+
# D5 never ran: no result at all.
|
|
389
|
+
unchecked = scan_text("He arrived at three.")
|
|
390
|
+
self.assertIsNone(unchecked.softening)
|
|
391
|
+
self.assertEqual(unchecked.softening_state(), "not-checked")
|
|
392
|
+
self.assertFalse(unchecked.clean())
|
|
393
|
+
|
|
394
|
+
def test_report_softening_flags_property(self):
|
|
395
|
+
unchecked = scan_text("He arrived at three.")
|
|
396
|
+
self.assertEqual(unchecked.softening_flags, [])
|
|
397
|
+
flagged = scan_text("He lied to me.",
|
|
398
|
+
restatement="It was complicated.")
|
|
399
|
+
self.assertEqual(flagged.softening_flags, flagged.softening.flags)
|
|
400
|
+
self.assertTrue(flagged.softening_flags)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class BatchScanTest(unittest.TestCase):
|
|
404
|
+
def test_scan_text_fires_everything(self):
|
|
405
|
+
report = scan_text(
|
|
406
|
+
"He's a liar. Actually, he tackled you because he panicked, but I adored him.",
|
|
407
|
+
restatement="So what you're saying is he made a mistake.",
|
|
408
|
+
)
|
|
409
|
+
self.assertTrue(report.subordinators)
|
|
410
|
+
self.assertTrue(report.verdict_words)
|
|
411
|
+
self.assertTrue(report.correction_candidates)
|
|
412
|
+
self.assertTrue(report.motive_flags)
|
|
413
|
+
self.assertTrue(report.softening_flags)
|
|
414
|
+
self.assertFalse(report.clean())
|
|
415
|
+
|
|
416
|
+
def test_scan_text_clean_with_restatement(self):
|
|
417
|
+
report = scan_text(
|
|
418
|
+
"He arrived at three. She left at four. Both of those happened.",
|
|
419
|
+
restatement="He arrived at three. She left at four.",
|
|
420
|
+
)
|
|
421
|
+
self.assertTrue(report.softening_checked)
|
|
422
|
+
self.assertEqual(report.softening_state(), "clean")
|
|
423
|
+
self.assertTrue(report.clean())
|
|
424
|
+
|
|
425
|
+
def test_scan_text_not_clean_when_d5_unchecked(self):
|
|
426
|
+
# PARAM-POSTMO-015: no restatement → D5 never ran → the report
|
|
427
|
+
# must not claim clean, and the state says so explicitly.
|
|
428
|
+
report = scan_text(
|
|
429
|
+
"He arrived at three. She left at four. Both of those happened."
|
|
430
|
+
)
|
|
431
|
+
self.assertFalse(report.softening_checked)
|
|
432
|
+
self.assertEqual(report.softening_state(), "not-checked")
|
|
433
|
+
self.assertFalse(report.clean())
|
|
434
|
+
|
|
435
|
+
def test_softening_state_flagged(self):
|
|
436
|
+
report = scan_text(
|
|
437
|
+
"It was a betrayal of my trust.",
|
|
438
|
+
restatement="It was a mistake.",
|
|
439
|
+
)
|
|
440
|
+
self.assertTrue(report.softening_checked)
|
|
441
|
+
self.assertEqual(report.softening_state(), "flagged")
|
|
442
|
+
self.assertFalse(report.clean())
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class LintExampleTest(unittest.TestCase):
|
|
446
|
+
def _run_lint(self, draft_text, extra_args=()):
|
|
447
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
448
|
+
draft = os.path.join(tmp, "draft.txt")
|
|
449
|
+
with open(draft, "w", encoding="utf-8") as f:
|
|
450
|
+
f.write(draft_text)
|
|
451
|
+
env = dict(os.environ,
|
|
452
|
+
PYTHONPATH=os.path.join(AUDIT_DIR, "src"))
|
|
453
|
+
return subprocess.run(
|
|
454
|
+
[sys.executable, "examples/lint_audit.py",
|
|
455
|
+
"--text", draft, *extra_args],
|
|
456
|
+
capture_output=True, text=True, cwd=AUDIT_DIR, env=env,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def test_cli_prints_unchecked_d5_notice(self):
|
|
460
|
+
proc = self._run_lint("He arrived at three. She left at four.\n")
|
|
461
|
+
self.assertEqual(proc.returncode, 0)
|
|
462
|
+
self.assertIn("softening not checked", proc.stdout)
|
|
463
|
+
self.assertIn("Not checked' is not 'no softening found'", proc.stdout)
|
|
464
|
+
|
|
465
|
+
def test_cli_no_notice_when_restatement_given(self):
|
|
466
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
467
|
+
rest = os.path.join(tmp, "rest.txt")
|
|
468
|
+
with open(rest, "w", encoding="utf-8") as f:
|
|
469
|
+
f.write("He arrived at three.\n")
|
|
470
|
+
proc = self._run_lint("He arrived at three. She left at four.\n",
|
|
471
|
+
extra_args=("--restatement", rest))
|
|
472
|
+
self.assertEqual(proc.returncode, 0)
|
|
473
|
+
self.assertNotIn("softening not checked", proc.stdout)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
unittest.main()
|
postmo-1.0.0/tests/test_scans.py
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
"""Tests for the mechanical linter checks (scans.py) and the discipline table."""
|
|
2
|
-
|
|
3
|
-
import unittest
|
|
4
|
-
|
|
5
|
-
from postmo.disciplines import D1, D2, D3, D4, D5, DISCIPLINES, discipline_name
|
|
6
|
-
from postmo.scans import (
|
|
7
|
-
Correction, CorrectionCandidate, HeldTension, MotiveFlag, PersonState,
|
|
8
|
-
ScanError, detect_softening, detect_supplied_motive,
|
|
9
|
-
find_correction_candidates, find_subordinators, find_verdict_words,
|
|
10
|
-
scan_text,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class DisciplineTableTest(unittest.TestCase):
|
|
15
|
-
def test_five_disciplines_numbered_1_to_5(self):
|
|
16
|
-
self.assertEqual(set(DISCIPLINES), {1, 2, 3, 4, 5})
|
|
17
|
-
|
|
18
|
-
def test_each_discipline_has_code_operator_split(self):
|
|
19
|
-
for n, d in DISCIPLINES.items():
|
|
20
|
-
for key in ("name", "rule", "mechanical", "operator"):
|
|
21
|
-
self.assertTrue(d[key].strip(), f"D{n} missing {key}")
|
|
22
|
-
|
|
23
|
-
def test_discipline_name(self):
|
|
24
|
-
self.assertEqual(discipline_name(D1), "hold-contradictions")
|
|
25
|
-
self.assertEqual(discipline_name(D5), "softening-self-check")
|
|
26
|
-
with self.assertRaises(ValueError):
|
|
27
|
-
discipline_name(6)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class SubordinatorScanTest(unittest.TestCase):
|
|
31
|
-
def test_finds_but_and_however(self):
|
|
32
|
-
self.assertIn("but", find_subordinators("X is true, but Y is also true."))
|
|
33
|
-
self.assertIn("however", find_subordinators("X is true. However, Y."))
|
|
34
|
-
|
|
35
|
-
def test_finds_multiword_connectives(self):
|
|
36
|
-
self.assertIn("which means", find_subordinators("X is true, which means Y."))
|
|
37
|
-
self.assertIn("in other words", find_subordinators("X. In other words, Y."))
|
|
38
|
-
|
|
39
|
-
def test_parallel_sentences_pass(self):
|
|
40
|
-
self.assertEqual(find_subordinators("X is true. Y is also true."), [])
|
|
41
|
-
|
|
42
|
-
def test_held_tension_accepts_parallel(self):
|
|
43
|
-
t = HeldTension(text="I adored her. I'm furious at her.")
|
|
44
|
-
self.assertTrue(t.text)
|
|
45
|
-
|
|
46
|
-
def test_held_tension_rejects_subordinator(self):
|
|
47
|
-
with self.assertRaises(ScanError):
|
|
48
|
-
HeldTension(text="I adored her, but I'm furious at her.")
|
|
49
|
-
with self.assertRaises(ScanError):
|
|
50
|
-
HeldTension(text="I adored her. However, I'm furious at her.")
|
|
51
|
-
|
|
52
|
-
def test_held_tension_rejects_empty(self):
|
|
53
|
-
with self.assertRaises(ScanError):
|
|
54
|
-
HeldTension(text=" ")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class VerdictWordScanTest(unittest.TestCase):
|
|
58
|
-
def test_flags_permanent_label(self):
|
|
59
|
-
hits = find_verdict_words("He's a liar, that's all there is to it.")
|
|
60
|
-
self.assertEqual(len(hits), 1)
|
|
61
|
-
self.assertEqual(hits[0].word, "liar")
|
|
62
|
-
|
|
63
|
-
def test_state_description_passes(self):
|
|
64
|
-
# "he was lying on Tuesday" is a time-indexed state, not a verdict.
|
|
65
|
-
self.assertEqual(find_verdict_words("He was lying to her on Tuesday."), [])
|
|
66
|
-
|
|
67
|
-
def test_adjective_state_passes(self):
|
|
68
|
-
# Adjectives can legitimately describe a moment — nouns only.
|
|
69
|
-
self.assertEqual(find_verdict_words("The situation was dangerous."), [])
|
|
70
|
-
|
|
71
|
-
def test_person_state_requires_all_fields(self):
|
|
72
|
-
with self.assertRaises(ScanError):
|
|
73
|
-
PersonState(when="", person="Dana", state="calm")
|
|
74
|
-
with self.assertRaises(ScanError):
|
|
75
|
-
PersonState(when="Tue", person=" ", state="calm")
|
|
76
|
-
ok = PersonState(when="2026-09-20", person="Dana", state="calm, cooperative")
|
|
77
|
-
self.assertEqual(ok.person, "Dana")
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class CorrectionTypingTest(unittest.TestCase):
|
|
81
|
-
def test_accepts_both_kinds(self):
|
|
82
|
-
self.assertEqual(Correction(kind="detail", text="3pm, not 2pm.").kind, "detail")
|
|
83
|
-
self.assertEqual(
|
|
84
|
-
Correction(kind="core-claim", text="She withdrew the claim.").kind,
|
|
85
|
-
"core-claim",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
def test_rejects_bad_kind_and_empty(self):
|
|
89
|
-
with self.assertRaises(ScanError):
|
|
90
|
-
Correction(kind="typo", text="whatever")
|
|
91
|
-
with self.assertRaises(ScanError):
|
|
92
|
-
Correction(kind="detail", text=" ")
|
|
93
|
-
|
|
94
|
-
def test_finds_correction_candidates(self):
|
|
95
|
-
found = find_correction_candidates(
|
|
96
|
-
"The meeting was at two. Actually, it was at three. Dana agreed."
|
|
97
|
-
)
|
|
98
|
-
self.assertEqual(len(found), 1)
|
|
99
|
-
self.assertIn("actually", found[0].marker)
|
|
100
|
-
self.assertTrue(isinstance(found[0], CorrectionCandidate))
|
|
101
|
-
|
|
102
|
-
def test_no_candidates_in_clean_text(self):
|
|
103
|
-
self.assertEqual(
|
|
104
|
-
find_correction_candidates("The meeting was at two. Dana agreed."), []
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
class MotiveScanTest(unittest.TestCase):
|
|
109
|
-
def test_flags_supplied_motive(self):
|
|
110
|
-
flags = detect_supplied_motive("He tackled you because he panicked.")
|
|
111
|
-
self.assertEqual(len(flags), 1)
|
|
112
|
-
self.assertEqual(flags[0].severity, "flag")
|
|
113
|
-
self.assertTrue(isinstance(flags[0], MotiveFlag))
|
|
114
|
-
|
|
115
|
-
def test_attributed_motive_is_clean(self):
|
|
116
|
-
# The teller assigned the why — discipline 4 satisfied.
|
|
117
|
-
self.assertEqual(
|
|
118
|
-
detect_supplied_motive("He said he panicked when it happened."), []
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def test_marked_guess_is_allowed_but_kept(self):
|
|
122
|
-
flags = detect_supplied_motive("If I had to guess, he panicked.")
|
|
123
|
-
self.assertEqual(len(flags), 1)
|
|
124
|
-
self.assertEqual(flags[0].severity, "guess")
|
|
125
|
-
|
|
126
|
-
def test_flags_driven_by_and_out_of(self):
|
|
127
|
-
self.assertTrue(detect_supplied_motive("It was driven by fear."))
|
|
128
|
-
self.assertTrue(detect_supplied_motive("She did it out of spite."))
|
|
129
|
-
|
|
130
|
-
def test_observed_sequence_passes(self):
|
|
131
|
-
self.assertEqual(
|
|
132
|
-
detect_supplied_motive("He tackled you. You fell. He walked away."), []
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
def test_motive_flag_rejects_bad_severity(self):
|
|
136
|
-
with self.assertRaises(ScanError):
|
|
137
|
-
MotiveFlag(sentence="x", pattern="y", severity="verdict")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class SofteningScanTest(unittest.TestCase):
|
|
141
|
-
def test_downgrade_betrayal_to_mistake(self):
|
|
142
|
-
flags = detect_softening(
|
|
143
|
-
"It was a betrayal of my trust.", "It was a mistake."
|
|
144
|
-
)
|
|
145
|
-
kinds = [f.kind for f in flags]
|
|
146
|
-
self.assertIn("downgrade", kinds)
|
|
147
|
-
down = next(f for f in flags if f.kind == "downgrade")
|
|
148
|
-
self.assertEqual(down.original_term, "betrayal")
|
|
149
|
-
self.assertEqual(down.replacement, "mistake")
|
|
150
|
-
|
|
151
|
-
def test_dropped_strong_term(self):
|
|
152
|
-
flags = detect_softening("He lied to me.", "It was complicated.")
|
|
153
|
-
self.assertTrue(any(
|
|
154
|
-
f.kind == "dropped" and f.original_term == "lied" for f in flags
|
|
155
|
-
))
|
|
156
|
-
|
|
157
|
-
def test_strong_term_kept_is_clean(self):
|
|
158
|
-
self.assertEqual(
|
|
159
|
-
detect_softening("He lied to me.", "He lied, and it hurt."), []
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
def test_tidy_restatement_phrase(self):
|
|
163
|
-
flags = detect_softening(
|
|
164
|
-
"I adored her. I'm furious at her.",
|
|
165
|
-
"So what you're saying is you have mixed feelings.",
|
|
166
|
-
)
|
|
167
|
-
self.assertTrue(any(f.kind == "tidy-restatement" for f in flags))
|
|
168
|
-
|
|
169
|
-
def test_ambivalence_resolved(self):
|
|
170
|
-
flags = detect_softening(
|
|
171
|
-
"I adored her. I'm furious at her.",
|
|
172
|
-
"The real feeling underneath is anger.",
|
|
173
|
-
)
|
|
174
|
-
self.assertTrue(any(f.kind == "ambivalence-resolved" for f in flags))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
class BatchScanTest(unittest.TestCase):
|
|
178
|
-
def test_scan_text_fires_everything(self):
|
|
179
|
-
report = scan_text(
|
|
180
|
-
"He's a liar. Actually, he tackled you because he panicked, but I adored him.",
|
|
181
|
-
restatement="So what you're saying is he made a mistake.",
|
|
182
|
-
)
|
|
183
|
-
self.assertTrue(report.subordinators)
|
|
184
|
-
self.assertTrue(report.verdict_words)
|
|
185
|
-
self.assertTrue(report.correction_candidates)
|
|
186
|
-
self.assertTrue(report.motive_flags)
|
|
187
|
-
self.assertTrue(report.softening_flags)
|
|
188
|
-
self.assertFalse(report.clean())
|
|
189
|
-
|
|
190
|
-
def test_scan_text_clean(self):
|
|
191
|
-
report = scan_text(
|
|
192
|
-
"He arrived at three. She left at four. Both of those happened."
|
|
193
|
-
)
|
|
194
|
-
self.assertTrue(report.clean())
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if __name__ == "__main__":
|
|
198
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|