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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postmo
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Hold a messy multi-person situation without resolving it: five audit disciplines as linter checks plus a full structured audit record.
5
5
  Author: Ryan Wesley Hitchcock
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "postmo"
7
- version = "1.0.0"
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, HeldTension, MotiveFlag, PersonState,
32
- ScanError, ScanReport, SofteningFlag, VerdictWordHit,
33
- detect_softening, detect_supplied_motive, find_correction_candidates,
34
- find_subordinators, find_verdict_words, scan_text,
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", "HeldTension", "MotiveFlag",
45
- "PersonState", "ScanError", "ScanReport", "SofteningFlag",
46
- "VerdictWordHit", "detect_softening", "detect_supplied_motive",
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
- lowered = sentence.lower()
191
- for marker in CORRECTION_MARKERS:
192
- if marker in lowered:
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
- def detect_softening(original: str, restatement: str) -> list[SofteningFlag]:
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
- softening_flags: list[SofteningFlag] = field(default_factory=list)
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 no check fired. Not a clean bill of health — see the
414
- honesty note at the top of this module."""
415
- return not (self.subordinators or self.verdict_words
416
- or self.correction_candidates or self.motive_flags
417
- or self.softening_flags)
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 = "") -> ScanReport:
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. Everything that fires is a REVIEW item for
425
- the operator — the scan finds, the operator decides.
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=find_correction_candidates(text),
644
+ correction_candidates=candidates,
431
645
  motive_flags=detect_supplied_motive(text),
432
- softening_flags=detect_softening(text, restatement) if restatement else [],
646
+ correction_coverage=correction_coverage(text, typed),
647
+ softening=detect_softening(text, restatement) if restatement else None,
433
648
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postmo
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Hold a messy multi-person situation without resolving it: five audit disciplines as linter checks plus a full structured audit record.
5
5
  Author: Ryan Wesley Hitchcock
6
6
  License: MIT
@@ -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()
@@ -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