postmo 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
postmo/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """postmo — hold a messy situation without resolving it.
2
+
3
+ Five audit disciplines, enforced as a linter where they're mechanical
4
+ and as explicit operator protocol where they need judgment:
5
+
6
+ 1. Hold contradictions as contradictions (parallel sentences, never "X, but Y").
7
+ 2. Track people as time-indexed states, never categorical verdicts.
8
+ 3. Type every correction: detail vs core-claim.
9
+ 4. Never supply motive or causality the teller didn't state.
10
+ 5. Self-check every output for softened threat language, supplied
11
+ generous interpretations, dropped contradictions, resolved ambivalence.
12
+
13
+ Two modes: direct audit (the headline — "don't resolve this, just hold
14
+ it", no engine needed) and overlay on the interrogation engine's
15
+ Decompose/Collide phases. Either way the canonical artifact is the
16
+ audit record: full structured data the next session can load, with
17
+ timestamps. The audit never resolves — the record schema rejects
18
+ synthesis/resolution/verdict sections.
19
+ """
20
+
21
+ from .disciplines import DISCIPLINES, D1, D2, D3, D4, D5, discipline_name
22
+ from .evidence import (
23
+ EvidenceError, EvidenceItem, check_matches_engine, normalize_tag, tag_list,
24
+ )
25
+ from .overlay import NEVER, attachment_for, describe_contract
26
+ from .record import (
27
+ PROTOCOL, AuditRecord, DisciplineEntry, RecordError, check_stand_down, parse,
28
+ utc_now,
29
+ )
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,
35
+ )
36
+
37
+ __all__ = [
38
+ "DISCIPLINES", "D1", "D2", "D3", "D4", "D5", "discipline_name",
39
+ "EvidenceError", "EvidenceItem", "check_matches_engine",
40
+ "normalize_tag", "tag_list",
41
+ "NEVER", "attachment_for", "describe_contract",
42
+ "PROTOCOL", "AuditRecord", "DisciplineEntry", "RecordError",
43
+ "check_stand_down", "parse", "utc_now",
44
+ "Correction", "CorrectionCandidate", "HeldTension", "MotiveFlag",
45
+ "PersonState", "ScanError", "ScanReport", "SofteningFlag",
46
+ "VerdictWordHit", "detect_softening", "detect_supplied_motive",
47
+ "find_correction_candidates", "find_subordinators", "find_verdict_words",
48
+ "scan_text",
49
+ ]
postmo/disciplines.py ADDED
@@ -0,0 +1,138 @@
1
+ """The five audit disciplines, stated once.
2
+
3
+ An audit is not a narrative: a narrative resolves tension toward a
4
+ single coherent story; an audit holds the actual shape of what's been
5
+ said — including the parts that don't resolve — and tracks it
6
+ accurately as it changes.
7
+
8
+ Each discipline carries its code/operator split explicitly:
9
+ `mechanical` is what `scans.py` checks; `operator` is what needs a
10
+ human or model judgment and stays protocol, not code. The split is
11
+ honest, not aspirational — a mechanical check that fired is a finding;
12
+ an operator check is a responsibility.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ D1 = 1 # hold contradictions as contradictions
18
+ D2 = 2 # track person-state as time-indexed, not categorical
19
+ D3 = 3 # distinguish detail-correction from core-claim correction
20
+ D4 = 4 # don't supply motive or causality the teller didn't state
21
+ D5 = 5 # self-check for softening and evasion — including your own
22
+
23
+ DISCIPLINES: dict[int, dict[str, str]] = {
24
+ D1: {
25
+ "name": "hold-contradictions",
26
+ "rule": (
27
+ "When two stated things are in tension, do not pick one side, "
28
+ "do not synthesize a third thing that subordinates one to the "
29
+ "other, and do not flag the tension as a problem needing "
30
+ "resolution. Both halves stay live. If a synthesis is true, "
31
+ "the teller states it — never supply it on their behalf."
32
+ ),
33
+ "mechanical": (
34
+ "Held tensions must be parallel non-subordinating sentences "
35
+ "('X is true. Y is also true.') — the subordinator scan in "
36
+ "scans.py rejects 'X, but/however/which means Y'."
37
+ ),
38
+ "operator": (
39
+ "Recognizing that two statements ARE in tension (the scan "
40
+ "checks grammar, not meaning); refusing to tidy the tension "
41
+ "in surrounding prose; leaving silence where no resolving "
42
+ "sentence is needed."
43
+ ),
44
+ },
45
+ D2: {
46
+ "name": "time-indexed-person-states",
47
+ "rule": (
48
+ "People are not 'good' or 'bad', 'safe' or 'dangerous' as fixed "
49
+ "labels — they are in different states at different points, and "
50
+ "all of those states are real simultaneously. Summarize "
51
+ "sequences of states, never verdicts on persons."
52
+ ),
53
+ "mechanical": (
54
+ "PersonState requires when + person + state, all non-empty; "
55
+ "the verdict-word scan flags permanent-label language "
56
+ "('he is a liar') for operator review."
57
+ ),
58
+ "operator": (
59
+ "Resisting the pull to declare one state 'the mask' and another "
60
+ "'the truth'; keeping the sequence ordered and complete."
61
+ ),
62
+ },
63
+ D3: {
64
+ "name": "correction-typing",
65
+ "rule": (
66
+ "Detail correction: a particular fact is revised (a time, an "
67
+ "object, a peripheral sequence detail) — it does not touch any "
68
+ "core claim standing elsewhere. Core-claim correction: a "
69
+ "decision, boundary, conclusion, or central assertion is "
70
+ "revised. Never let a detail correction trigger re-litigation "
71
+ "of an uncorrected core claim. If ambiguous which kind, ask "
72
+ "rather than guess."
73
+ ),
74
+ "mechanical": (
75
+ "Correction records accept only 'detail' or 'core-claim'; the "
76
+ "correction-candidate scan flags correction-like sentences "
77
+ "('actually…', 'no wait…', 'to be clear…') that haven't been "
78
+ "typed yet."
79
+ ),
80
+ "operator": (
81
+ "Typing ambiguous corrections (ask, don't guess); holding the "
82
+ "line that a detail fix does not reopen standing core claims."
83
+ ),
84
+ },
85
+ D4: {
86
+ "name": "no-supplied-motive",
87
+ "rule": (
88
+ "Track what happened and what was said — not why, unless the "
89
+ "teller assigns the why. 'He tackled you because he panicked' "
90
+ "is an invented motive, even if plausible. State the observed "
91
+ "sequence: who did what, in what order. A directly requested "
92
+ "read on motive is a flagged guess ('if I had to guess…'), "
93
+ "never narrated as settled fact."
94
+ ),
95
+ "mechanical": (
96
+ "The motive scan flags causal/motive attributions ('because he "
97
+ "panicked', 'driven by', 'in order to') that carry no "
98
+ "attribution marker ('he said', 'according to', quotes) and no "
99
+ "explicit guess-flag."
100
+ ),
101
+ "operator": (
102
+ "The final call on whether a flagged attribution was actually "
103
+ "supplied by the teller in surrounding context the scan can't "
104
+ "see; keeping flagged guesses flagged."
105
+ ),
106
+ },
107
+ D5: {
108
+ "name": "softening-self-check",
109
+ "rule": (
110
+ "Before finalizing, check: did a threat/betrayal/harm get "
111
+ "gentler language than the teller used? Did a generous "
112
+ "interpretation get supplied unasked? Did a restatement drop a "
113
+ "contradiction or tidy it? Did ambivalence get resolved into "
114
+ "one 'real' feeling? Did a direct question get answered "
115
+ "evasively? The noticing is part of the deliverable. Also runs "
116
+ "outward: name the teller's own smoothing once, plainly, "
117
+ "without diagnosing — don't press, don't repeat if unpicked."
118
+ ),
119
+ "mechanical": (
120
+ "The softening scan compares original vs restatement: "
121
+ "downgrade wordlist ('betrayal' → 'mistake'), dropped strong "
122
+ "terms, tidy-restatement phrases ('so what you're saying is'), "
123
+ "ambivalence-resolution phrases ('the real feeling is')."
124
+ ),
125
+ "operator": (
126
+ "Generous-interpretation detection; evasive-answer detection; "
127
+ "judging whether a flagged downgrade is a genuine softening or "
128
+ "the teller's own word choice."
129
+ ),
130
+ },
131
+ }
132
+
133
+
134
+ def discipline_name(n: int) -> str:
135
+ """Short name for discipline n (1-5)."""
136
+ if n not in DISCIPLINES:
137
+ raise ValueError(f"Unknown discipline: {n!r}. Valid: 1-5.")
138
+ return DISCIPLINES[n]["name"]
postmo/evidence.py ADDED
@@ -0,0 +1,146 @@
1
+ """The evidence taxonomy this package uses.
2
+
3
+ The audit never invented its own evidence tiers — it tracks claims,
4
+ states, and corrections, not tagged evidence. Where the audit record
5
+ carries evidence items, it uses the ONE authoritative taxonomy shared
6
+ across this conversion project:
7
+
8
+ - `adversarial_ifs` (the interrogation engine, `ifs/` conversion):
9
+ 11 tags — 10 core tiers plus overlay-only OBFUSCATION.
10
+ - `evasion_audit` (the probe+forge conversion, `probe-forge/`):
11
+ the same 11 tags, vendored with a fork guard.
12
+
13
+ This module vendors the same set a third time, with the same guard:
14
+ `check_matches_engine()` asserts the taxonomies are identical when
15
+ `adversarial_ifs` is importable, and skips when it isn't. Three
16
+ vendored copies plus two guards is clunkier than one shared
17
+ dependency — it is deliberate. Every package in this suite stays
18
+ stdlib-only and installable alone; the guards, not imports, keep the
19
+ taxonomies from silently forking.
20
+
21
+ There is no second taxonomy here. There never was one in the skill.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ # --- The authoritative taxonomy (11 tags) -----------------------------------
29
+
30
+ FACT = "FACT"
31
+ OBSERVATION = "OBSERVATION"
32
+ CLAIM = "CLAIM"
33
+ INFERENCE = "INFERENCE"
34
+ ASSUMPTION = "ASSUMPTION"
35
+ HYPOTHESIS = "HYPOTHESIS"
36
+ REQUIREMENT = "REQUIREMENT"
37
+ CONSTRAINT = "CONSTRAINT"
38
+ DEPENDENCY = "DEPENDENCY"
39
+ UNKNOWN = "UNKNOWN"
40
+ # Overlay-only: set only when the evasion probe ran AND the evasion
41
+ # hypothesis is supported by discriminating evidence.
42
+ OBFUSCATION = "OBFUSCATION"
43
+
44
+ TAXONOMY: dict[str, str] = {
45
+ FACT: "Directly established, unassailable ground truth.",
46
+ OBSERVATION: "Reported or logged, but not independently verified.",
47
+ CLAIM: "An explicit assertion made by an actor or source.",
48
+ INFERENCE: "A conclusion logically drawn from facts or verified observations.",
49
+ ASSUMPTION: "Presumed true without adequate supporting evidence.",
50
+ HYPOTHESIS: "A proposed explanatory model that still needs stress-testing.",
51
+ REQUIREMENT: "A condition that must be true for a model to hold.",
52
+ CONSTRAINT: "A boundary condition limiting viable outcomes.",
53
+ DEPENDENCY: "An element relying directly on the state of another.",
54
+ UNKNOWN: "A critical variable that cannot presently be determined.",
55
+ OBFUSCATION: (
56
+ # Definition text is identical to adversarial_ifs.evidence on
57
+ # purpose — check_matches_engine() enforces byte-identical
58
+ # definitions so the taxonomies can never silently fork.
59
+ "Evasive maneuver or jargon shield masking responsibility. "
60
+ "Tag ONLY when the evasion-probe overlay is active and the evasion "
61
+ "hypothesis is supported by discriminating evidence."
62
+ ),
63
+ }
64
+
65
+ CORE_TAGS = frozenset(
66
+ {FACT, OBSERVATION, CLAIM, INFERENCE, ASSUMPTION, HYPOTHESIS,
67
+ REQUIREMENT, CONSTRAINT, DEPENDENCY, UNKNOWN}
68
+ )
69
+
70
+
71
+ class EvidenceError(ValueError):
72
+ """Raised when an evidence item breaks the taxonomy rules."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class EvidenceItem:
77
+ """One tagged input to the audit."""
78
+
79
+ text: str
80
+ tag: str
81
+
82
+ def __post_init__(self) -> None:
83
+ tag = self.tag.strip().upper()
84
+ if tag not in TAXONOMY:
85
+ raise EvidenceError(
86
+ f"Unknown evidence tag {self.tag!r}. "
87
+ f"Valid tags: {', '.join(sorted(TAXONOMY))}."
88
+ )
89
+ if not self.text.strip():
90
+ raise EvidenceError("Evidence item text must not be empty.")
91
+
92
+
93
+ def normalize_tag(tag: str, *, probe_ran: bool = False) -> str:
94
+ """Validate and normalize a tag. OBFUSCATION needs the probe.
95
+
96
+ The audit does not run the evasion probe itself. If a record claims
97
+ OBFUSCATION, the caller must pass probe_ran=True — meaning the probe
98
+ ran elsewhere and H-obfuscation was supported by discriminating
99
+ evidence. Suspected-but-untested evasion stays CLAIM.
100
+ """
101
+ normalized = tag.strip().upper()
102
+ if normalized not in TAXONOMY:
103
+ raise EvidenceError(f"Unknown evidence tag {tag!r}.")
104
+ if normalized == OBFUSCATION and not probe_ran:
105
+ raise EvidenceError(
106
+ "OBFUSCATION is overlay-only: it may be tagged only when the "
107
+ "evasion probe ran and H-obfuscation is supported by "
108
+ "discriminating evidence. Use CLAIM with a hypothesis marker "
109
+ "for suspected-but-untested evasion."
110
+ )
111
+ return normalized
112
+
113
+
114
+ def check_matches_engine() -> str:
115
+ """Assert this taxonomy matches adversarial_ifs' — guard against forks.
116
+
117
+ Returns "matches" on success. Skips (returns "skipped: adversarial_ifs
118
+ not importable") when the sibling package isn't on the path — the
119
+ vendored copy above remains authoritative either way.
120
+ """
121
+ try:
122
+ import adversarial_ifs.evidence as engine_evidence
123
+ except ImportError:
124
+ return "skipped: adversarial_ifs not importable"
125
+ ours = set(TAXONOMY)
126
+ theirs = set(engine_evidence.TAXONOMY)
127
+ if ours != theirs:
128
+ raise EvidenceError(
129
+ "Taxonomy fork detected: postmo and adversarial_ifs "
130
+ f"disagree. Only here: {sorted(ours - theirs)}; "
131
+ f"only in engine: {sorted(theirs - ours)}. "
132
+ "Reconcile before shipping."
133
+ )
134
+ for tag in ours:
135
+ if TAXONOMY[tag] != engine_evidence.TAXONOMY[tag]:
136
+ raise EvidenceError(
137
+ f"Taxonomy fork detected: definition of {tag} differs "
138
+ "between postmo and adversarial_ifs. Reconcile "
139
+ "before shipping."
140
+ )
141
+ return "matches"
142
+
143
+
144
+ def tag_list() -> list[str]:
145
+ """All valid tags, in canonical order."""
146
+ return list(TAXONOMY)
postmo/ledger.py ADDED
@@ -0,0 +1,132 @@
1
+ """Append-only local ledger. Vendored per tool; stdlib only.
2
+
3
+ CANONICAL COPY: edit this file, then re-vendor with vendor.sh.
4
+ Do not edit the vendored copies in place.
5
+
6
+ Privacy rule (non-negotiable): the ledger NEVER stores raw input text.
7
+ Records carry SHA-256 digests of inputs plus structural summaries
8
+ (counts, verdicts, deltas). What happened, and what kind — never the words.
9
+
10
+ Location: ~/.frontface/<tool>/ledger.jsonl
11
+ override: FRONTFACE_LEDGER_DIR=<dir> (ledgers go to <dir>/<tool>/)
12
+ disable: FRONTFACE_NO_LEDGER=1 (record() becomes a silent no-op)
13
+
14
+ The ledger must never break the tool it serves: record() swallows OSError
15
+ and returns False instead of raising.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import sys
24
+ from datetime import datetime, timezone
25
+
26
+ try:
27
+ import fcntl
28
+ except ImportError: # pragma: no cover — Windows has no fcntl
29
+ fcntl = None # type: ignore[assignment]
30
+
31
+
32
+ def _base_dir() -> str:
33
+ return os.environ.get("FRONTFACE_LEDGER_DIR") or os.path.join(
34
+ os.path.expanduser("~"), ".frontface"
35
+ )
36
+
37
+
38
+ def _disabled() -> bool:
39
+ return os.environ.get("FRONTFACE_NO_LEDGER") == "1"
40
+
41
+
42
+ class Ledger:
43
+ """One JSONL ledger per tool. Cheap to construct; construct per use."""
44
+
45
+ def __init__(self, tool: str) -> None:
46
+ self.tool = tool
47
+ self.dir = os.path.join(_base_dir(), tool)
48
+ self.path = os.path.join(self.dir, "ledger.jsonl")
49
+
50
+ @property
51
+ def version(self) -> str:
52
+ """The owning package's __version__, resolved lazily."""
53
+ try:
54
+ pkg = sys.modules.get(__package__ or "")
55
+ return str(getattr(pkg, "__version__", "unknown"))
56
+ except Exception:
57
+ return "unknown"
58
+
59
+ @staticmethod
60
+ def digest(text: str) -> str:
61
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
62
+
63
+ def record(
64
+ self,
65
+ event: str,
66
+ summary: dict,
67
+ *,
68
+ input_text: str | None = None,
69
+ ) -> bool:
70
+ """Append one record. Returns False when disabled or on I/O error.
71
+
72
+ Never raises. Never stores raw input text — only its digest.
73
+ """
74
+ if _disabled():
75
+ return False
76
+ rec: dict = {
77
+ "ts": datetime.now(timezone.utc).isoformat(),
78
+ "tool": self.tool,
79
+ "tool_version": self.version,
80
+ "event": event,
81
+ "summary": summary,
82
+ }
83
+ if input_text is not None:
84
+ rec["input_digest"] = self.digest(input_text)
85
+ try:
86
+ os.makedirs(self.dir, exist_ok=True)
87
+ with open(self.path, "a", encoding="utf-8") as f:
88
+ if fcntl is not None:
89
+ fcntl.flock(f, fcntl.LOCK_EX)
90
+ try:
91
+ f.write(json.dumps(rec) + "\n")
92
+ f.flush()
93
+ os.fsync(f.fileno())
94
+ finally:
95
+ fcntl.flock(f, fcntl.LOCK_UN)
96
+ else:
97
+ f.write(json.dumps(rec) + "\n")
98
+ except OSError:
99
+ return False
100
+ return True
101
+
102
+ def read(self, limit: int | None = None) -> list[dict]:
103
+ """All records, oldest first. Empty list when there's no ledger."""
104
+ try:
105
+ with open(self.path, encoding="utf-8") as f:
106
+ lines = f.read().splitlines()
107
+ except OSError:
108
+ return []
109
+ recs = []
110
+ for line in lines:
111
+ line = line.strip()
112
+ if line:
113
+ try:
114
+ recs.append(json.loads(line))
115
+ except ValueError:
116
+ continue # never let a corrupt line kill the read
117
+ return recs[-limit:] if limit else recs
118
+
119
+ def summary(self) -> dict:
120
+ """Counts by event plus first/last timestamps."""
121
+ recs = self.read()
122
+ by_event: dict[str, int] = {}
123
+ for r in recs:
124
+ ev = str(r.get("event", "?"))
125
+ by_event[ev] = by_event.get(ev, 0) + 1
126
+ return {
127
+ "tool": self.tool,
128
+ "records": len(recs),
129
+ "events": by_event,
130
+ "first": recs[0]["ts"] if recs else None,
131
+ "last": recs[-1]["ts"] if recs else None,
132
+ }
postmo/overlay.py ADDED
@@ -0,0 +1,74 @@
1
+ """The overlay contract: where this audit attaches, and what it never does.
2
+
3
+ As an overlay on the interrogation engine (`adversarial_ifs`), the audit
4
+ attaches at two phase hooks:
5
+
6
+ - Decompose: disciplines 2 (time-indexed person-states), 3 (correction
7
+ typing), and 4 (no supplied motive) govern actor mapping, corrections
8
+ in the source material, and the decomposed field (observed sequences
9
+ only).
10
+ - Collide: discipline 1 (hold contradictions — the strict enforcer: no
11
+ subordinating conjunctions, no synthesized third thing, no flagging
12
+ tensions as problems) and discipline 5 (softening self-check on every
13
+ collision output before it passes onward).
14
+
15
+ The overlay never: resolves a contradiction the engine is holding,
16
+ supplies the synthesis (that's the engine's Synthesize phase, or the
17
+ user's), or converts a direct-mode audit into an interrogation unasked.
18
+
19
+ No engine running: the overlay reports the audit record and stops.
20
+ Direct audit mode is the headline use — "don't resolve this, just hold
21
+ it" — and needs no engine at all.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .disciplines import D1, D2, D3, D4, D5
27
+
28
+ # Phase hooks: engine phase -> disciplines enforced there.
29
+ ATTACHMENT_POINTS: dict[str, tuple[int, ...]] = {
30
+ "Decompose": (D2, D3, D4),
31
+ "Collide": (D1, D5),
32
+ }
33
+
34
+ # What the overlay must never do. Enforced at the schema level by
35
+ # record.check_stand_down (no synthesis/resolution/verdict sections)
36
+ # and stated here so the contract is readable in one place.
37
+ NEVER = (
38
+ "resolve a contradiction the engine is holding",
39
+ "supply the synthesis on the teller's behalf",
40
+ "convert a direct-mode audit into an interrogation unasked",
41
+ "soften threat, danger, harm, or betrayal language",
42
+ "treat one correction as license to revisit unrelated standing claims",
43
+ )
44
+
45
+
46
+ class OverlayError(ValueError):
47
+ """Raised when overlay input breaks the contract."""
48
+
49
+
50
+ def attachment_for(phase: str) -> tuple[int, ...]:
51
+ """Disciplines enforced at an engine phase."""
52
+ if phase not in ATTACHMENT_POINTS:
53
+ raise OverlayError(
54
+ f"Unknown engine phase {phase!r}. "
55
+ f"Known: {', '.join(ATTACHMENT_POINTS)}."
56
+ )
57
+ return ATTACHMENT_POINTS[phase]
58
+
59
+
60
+ def describe_contract() -> str:
61
+ """The contract as human-readable text."""
62
+ lines = ["Audit overlay contract:", ""]
63
+ for phase, disciplines in ATTACHMENT_POINTS.items():
64
+ lines.append(f"- On {phase}: disciplines {', '.join(map(str, disciplines))}.")
65
+ lines.append("")
66
+ lines.append("The overlay never:")
67
+ for item in NEVER:
68
+ lines.append(f"- {item}.")
69
+ lines.append("")
70
+ lines.append(
71
+ "No engine running: report the audit record and stop. "
72
+ "Direct audit mode needs no engine."
73
+ )
74
+ return "\n".join(lines)