fairit 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.
fairit/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """fairit — pre-interrogation ground mapping and evasion testing.
2
+
3
+ Map the ground (held tensions, time-indexed states, typed corrections),
4
+ test evasion hypotheses (never presume them), and hand off 2-3 seed
5
+ frames to whatever comes next. The package never collides, adjudicates,
6
+ refines, or extracts surprise — it audits and hands off.
7
+ """
8
+
9
+ from .brief import AuditBrief, BriefError, check_stand_down, parse
10
+ from .dialects import DIALECTS, SHIELD_MARKERS, detect_markers
11
+ from .evidence import (
12
+ TAXONOMY,
13
+ EvidenceError,
14
+ EvidenceItem,
15
+ check_matches_engine,
16
+ forge_mapping_table,
17
+ normalize_tag,
18
+ tag_list,
19
+ )
20
+ from .ground import (
21
+ Correction,
22
+ GroundError,
23
+ GroundMap,
24
+ HeldTension,
25
+ PersonState,
26
+ find_subordinators,
27
+ )
28
+ from .hypotheses import (
29
+ H_BOTH,
30
+ H_GENUINE,
31
+ H_OBFUSCATION,
32
+ INSUFFICIENT_EVIDENCE,
33
+ SUPPORT_STATUSES,
34
+ VERDICTS,
35
+ CategoryPresumptionError,
36
+ DiscriminatingEvidence,
37
+ HypothesisError,
38
+ HypothesisRecord,
39
+ Mechanism,
40
+ )
41
+ from .overlay import (
42
+ JargonFlag,
43
+ ObfuscatedObject,
44
+ ProbeError,
45
+ evidence_tag_for,
46
+ run_jargon_test,
47
+ scan_and_test,
48
+ )
49
+
50
+ __all__ = [
51
+ "AuditBrief", "BriefError", "check_stand_down", "parse",
52
+ "DIALECTS", "SHIELD_MARKERS", "detect_markers",
53
+ "TAXONOMY", "EvidenceError", "EvidenceItem", "check_matches_engine",
54
+ "forge_mapping_table", "normalize_tag", "tag_list",
55
+ "Correction", "GroundError", "GroundMap", "HeldTension", "PersonState",
56
+ "find_subordinators",
57
+ "H_BOTH", "H_GENUINE", "H_OBFUSCATION", "INSUFFICIENT_EVIDENCE",
58
+ "SUPPORT_STATUSES", "VERDICTS", "CategoryPresumptionError",
59
+ "DiscriminatingEvidence", "HypothesisError", "HypothesisRecord", "Mechanism",
60
+ "JargonFlag", "ObfuscatedObject", "ProbeError", "evidence_tag_for",
61
+ "run_jargon_test", "scan_and_test",
62
+ ]
63
+
64
+ __version__ = "1.0.0"
fairit/brief.py ADDED
@@ -0,0 +1,131 @@
1
+ """The audit brief — the overlay's output artifact.
2
+
3
+ The brief records what was audited, which evasion hypotheses were tested
4
+ and their support status, and what seeds were handed off. It is a brief,
5
+ not an interrogation result: the interrogation engine's state artifact
6
+ stays canonical.
7
+
8
+ Stand-down rule, enforced in code: the brief must NEVER contain
9
+ collision, adjudication, refinement, or surprise sections. Those belong
10
+ to the interrogation engine. If the engine isn't running, the brief is
11
+ reported and the work stops.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from datetime import date
19
+
20
+ from .ground import GroundMap
21
+ from .hypotheses import HypothesisRecord, H_OBFUSCATION
22
+
23
+ # Headings the overlay must never emit. A brief that collides or
24
+ # adjudicates has left its lane — the schema rejects it.
25
+ FORBIDDEN_SECTIONS = (
26
+ "collision", "collide", "adjudicat", "refinement", "refine",
27
+ "surprise",
28
+ )
29
+
30
+
31
+ class BriefError(ValueError):
32
+ """Raised when a brief breaks the schema or the stand-down rule."""
33
+
34
+
35
+ def check_stand_down(rendered: str) -> None:
36
+ """Reject rendered text that contains engine-owned sections."""
37
+ headings = re.findall(r"^#{1,3}\s*(.+)$", rendered, re.MULTILINE)
38
+ for heading in headings:
39
+ lowered = heading.lower()
40
+ for forbidden in FORBIDDEN_SECTIONS:
41
+ if forbidden in lowered:
42
+ raise BriefError(
43
+ f"Stand-down violation: the brief must not contain a "
44
+ f"'{heading.strip()}' section. Collision, adjudication, "
45
+ "refinement, and surprise belong to the interrogation "
46
+ "engine — this overlay audits and hands off, nothing more."
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class AuditBrief:
52
+ """Audited ground + tested hypotheses + handoff seeds."""
53
+
54
+ case_name: str
55
+ ground: GroundMap = field(default_factory=GroundMap)
56
+ records: list[HypothesisRecord] = field(default_factory=list)
57
+ seeds: list[str] = field(default_factory=list)
58
+ lifecycle: str = "FINAL"
59
+ brief_date: str = field(default_factory=lambda: date.today().isoformat())
60
+
61
+ def __post_init__(self) -> None:
62
+ if not self.case_name.strip():
63
+ raise BriefError("Brief needs a case name.")
64
+ if self.lifecycle not in ("INITIAL", "ITERATIVE", "FINAL"):
65
+ raise BriefError(f"Bad lifecycle: {self.lifecycle!r}.")
66
+ if not (2 <= len(self.seeds) <= 3):
67
+ raise BriefError(
68
+ f"Handoff must produce 2-3 seed frames, got {len(self.seeds)}."
69
+ )
70
+ for seed in self.seeds:
71
+ if not seed.strip():
72
+ raise BriefError("Seed frames must not be empty.")
73
+
74
+ def obfuscation_supported(self) -> bool:
75
+ """True only when some record's H-obfuscation is supported."""
76
+ return any(r.supports_obfuscation() for r in self.records)
77
+
78
+ def render(self) -> str:
79
+ lines = [
80
+ "---",
81
+ "artifact: audit-brief",
82
+ f"date: {self.brief_date}",
83
+ "protocol: fairit/1.0",
84
+ f"lifecycle: {self.lifecycle}",
85
+ "---",
86
+ "",
87
+ f"# Audit brief: {self.case_name}",
88
+ "",
89
+ self.ground.render(),
90
+ "",
91
+ "## Tested hypotheses",
92
+ ]
93
+ for record in self.records:
94
+ lines.append("")
95
+ lines.append(record.summary())
96
+ lines.extend(["", "## Handoff seeds"])
97
+ for i, seed in enumerate(self.seeds, 1):
98
+ lines.append(f"{i}. {seed}")
99
+ rendered = "\n".join(lines)
100
+ check_stand_down(rendered)
101
+ return rendered
102
+
103
+
104
+ def parse(rendered: str) -> AuditBrief:
105
+ """Parse a rendered brief back into an AuditBrief.
106
+
107
+ Round-trip fidelity: case name, lifecycle, date, and seeds survive.
108
+ Ground and hypothesis records parse back as their raw text — the
109
+ structured re-validation is the caller's job on re-entry.
110
+ """
111
+ check_stand_down(rendered)
112
+ front = dict(
113
+ line.split(":", 1)
114
+ for line in rendered.splitlines()
115
+ if re.match(r"^(artifact|date|protocol|lifecycle):", line)
116
+ )
117
+ if front.get("artifact", "").strip() != "audit-brief":
118
+ raise BriefError("Not an audit-brief artifact.")
119
+ match = re.search(r"^# Audit brief: (.+)$", rendered, re.MULTILINE)
120
+ if not match:
121
+ raise BriefError("Brief is missing its case-name heading.")
122
+ seed_lines: list[str] = []
123
+ if "## Handoff seeds" in rendered:
124
+ tail = rendered.split("## Handoff seeds", 1)[1]
125
+ seed_lines = [s.strip() for s in re.findall(r"^\d+\. (.+)$", tail, re.MULTILINE)]
126
+ return AuditBrief(
127
+ case_name=match.group(1).strip(),
128
+ lifecycle=front.get("lifecycle", "FINAL").strip() or "FINAL",
129
+ brief_date=front.get("date", "").strip() or date.today().isoformat(),
130
+ seeds=seed_lines,
131
+ )
fairit/dialects.py ADDED
@@ -0,0 +1,54 @@
1
+ """Shield-vocabulary marker lists, by dialect.
2
+
3
+ These lists detect *candidate* instances of jargon that MAY function as
4
+ evasion. Detection is the start of the hypothesis protocol, never its
5
+ conclusion: a marker hit feeds H-obfuscation / H-genuine / H-both
6
+ testing in `hypotheses.py`. No marker, list, or dialect ever produces a
7
+ verdict by itself.
8
+
9
+ The original skill only knew therapy-speak. The same structure covers
10
+ corporate-speak and bureaucratic passive voice — the mechanism doesn't
11
+ care which dialect the shield is written in.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # (dialect, phrases). Phrases are matched case-insensitively as
17
+ # substrings — they are tripwires, not judgments.
18
+ SHIELD_MARKERS: dict[str, tuple[str, ...]] = {
19
+ "therapy": (
20
+ "holding space", "doing the work", "inner child", "boundaries",
21
+ "triggered", "processing", "my truth", "self-care", "nervous system",
22
+ "regulation", "co-regulation", "shadow work", "attachment style",
23
+ "parts of me", "sitting with", "unpacking",
24
+ ),
25
+ "corporate": (
26
+ "circle back", "synergy", "leverage", "bandwidth", "deep dive",
27
+ "move the needle", "take this offline", "low-hanging fruit",
28
+ "best practices", "thought leadership", "rightsizing",
29
+ "synergize", "actionable", "paradigm",
30
+ ),
31
+ "bureaucratic": (
32
+ "mistakes were made", "regrettable", "inappropriate", "it was decided",
33
+ "processes were followed", "at this time", "going forward",
34
+ "lessons learned", "unfortunate", "miscommunication",
35
+ "administrative error", "procedural",
36
+ ),
37
+ }
38
+
39
+ DIALECTS = tuple(SHIELD_MARKERS)
40
+
41
+
42
+ def detect_markers(text: str) -> list[tuple[str, str]]:
43
+ """Find candidate shield-vocabulary instances.
44
+
45
+ Returns a list of (dialect, phrase) hits. A hit is a candidate for
46
+ hypothesis testing — it says "examine this," nothing more.
47
+ """
48
+ lowered = text.lower()
49
+ hits: list[tuple[str, str]] = []
50
+ for dialect, phrases in SHIELD_MARKERS.items():
51
+ for phrase in phrases:
52
+ if phrase in lowered:
53
+ hits.append((dialect, phrase))
54
+ return hits
fairit/evidence.py ADDED
@@ -0,0 +1,177 @@
1
+ """The authoritative evidence taxonomy for this package.
2
+
3
+ Lineage: the interrogation engine (`adversarial_ifs`, in the sibling
4
+ `ifs/` conversion) defines an 11-tag taxonomy — 10 core tiers plus one
5
+ overlay-only tag (OBFUSCATION). field-forge shipped its own 8-tier list.
6
+ This module is the ONE authoritative mapping: field-forge's 8 tiers map
7
+ 1:1 onto the engine's taxonomy, and the 3 tiers the forge never named
8
+ (HYPOTHESIS, REQUIREMENT, DEPENDENCY) are documented as recognized —
9
+ not as a second taxonomy.
10
+
11
+ The authoritative set is vendored here (with the lineage note above) so
12
+ this package stays standalone, stdlib-only, with no path dependency on
13
+ its sibling. `check_matches_engine()` guards against silent forks: if
14
+ `adversarial_ifs` is importable, it asserts the two taxonomies are
15
+ identical; if it isn't, the check skips and the vendored copy stands.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # --- The authoritative taxonomy (11 tags) -----------------------------------
23
+
24
+ FACT = "FACT"
25
+ OBSERVATION = "OBSERVATION"
26
+ CLAIM = "CLAIM"
27
+ INFERENCE = "INFERENCE"
28
+ ASSUMPTION = "ASSUMPTION"
29
+ HYPOTHESIS = "HYPOTHESIS"
30
+ REQUIREMENT = "REQUIREMENT"
31
+ CONSTRAINT = "CONSTRAINT"
32
+ DEPENDENCY = "DEPENDENCY"
33
+ UNKNOWN = "UNKNOWN"
34
+ # Overlay-only: set only when the evasion probe ran AND the evasion
35
+ # hypothesis is supported by discriminating evidence.
36
+ OBFUSCATION = "OBFUSCATION"
37
+
38
+ TAXONOMY: dict[str, str] = {
39
+ FACT: "Directly established, unassailable ground truth.",
40
+ OBSERVATION: "Reported or logged, but not independently verified.",
41
+ CLAIM: "An explicit assertion made by an actor or source.",
42
+ INFERENCE: "A conclusion logically drawn from facts or verified observations.",
43
+ ASSUMPTION: "Presumed true without adequate supporting evidence.",
44
+ HYPOTHESIS: "A proposed explanatory model that still needs stress-testing.",
45
+ REQUIREMENT: "A condition that must be true for a model to hold.",
46
+ CONSTRAINT: "A boundary condition limiting viable outcomes.",
47
+ DEPENDENCY: "An element relying directly on the state of another.",
48
+ UNKNOWN: "A critical variable that cannot presently be determined.",
49
+ OBFUSCATION: (
50
+ # Definition text is identical to adversarial_ifs.evidence on
51
+ # purpose — check_matches_engine() enforces byte-identical
52
+ # definitions so the two taxonomies can never silently fork.
53
+ "Evasive maneuver or jargon shield masking responsibility. "
54
+ "Tag ONLY when the evasion-probe overlay is active and the evasion "
55
+ "hypothesis is supported by discriminating evidence."
56
+ ),
57
+ }
58
+
59
+ CORE_TAGS = frozenset(
60
+ {FACT, OBSERVATION, CLAIM, INFERENCE, ASSUMPTION, HYPOTHESIS,
61
+ REQUIREMENT, CONSTRAINT, DEPENDENCY, UNKNOWN}
62
+ )
63
+
64
+
65
+ class EvidenceError(ValueError):
66
+ """Raised when an evidence item breaks the taxonomy rules."""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class EvidenceItem:
71
+ """One tagged input to the audit."""
72
+
73
+ text: str
74
+ tag: str
75
+
76
+ def __post_init__(self) -> None:
77
+ tag = self.tag.strip().upper()
78
+ if tag not in TAXONOMY:
79
+ raise EvidenceError(
80
+ f"Unknown evidence tag {self.tag!r}. "
81
+ f"Valid tags: {', '.join(sorted(TAXONOMY))}."
82
+ )
83
+ if not self.text.strip():
84
+ raise EvidenceError("Evidence item text must not be empty.")
85
+
86
+
87
+ def normalize_tag(tag: str, *, probe_ran: bool = False) -> str:
88
+ """Validate and normalize a tag. OBFUSCATION needs the probe.
89
+
90
+ The probe-run requirement is the taxonomy-level twin of the
91
+ hypothesis gate in `hypotheses.py`: the tag may exist on paper,
92
+ but it may only be *used* when H-obfuscation was actually supported.
93
+ """
94
+ normalized = tag.strip().upper()
95
+ if normalized not in TAXONOMY:
96
+ raise EvidenceError(f"Unknown evidence tag {tag!r}.")
97
+ if normalized == OBFUSCATION and not probe_ran:
98
+ raise EvidenceError(
99
+ "OBFUSCATION is overlay-only: it may be tagged only when the "
100
+ "evasion probe ran and H-obfuscation is supported by "
101
+ "discriminating evidence. Use CLAIM with a hypothesis marker "
102
+ "for suspected-but-untested evasion."
103
+ )
104
+ return normalized
105
+
106
+
107
+ # --- The field-forge reconciliation ------------------------------------------
108
+ #
109
+ # field-forge's 8-tier list (from its SKILL.md), mapped onto the
110
+ # authoritative taxonomy. Every forge tier maps 1:1 — no renames, no
111
+ # splits. The 3 tiers the forge never named are listed as RECOGNIZED:
112
+ # valid tags the forge simply didn't use, now available everywhere.
113
+ #
114
+ # forge FACT -> FACT
115
+ # forge OBSERVATION -> OBSERVATION
116
+ # forge CLAIM -> CLAIM
117
+ # forge OBFUSCATION -> OBFUSCATION (same overlay-only rule)
118
+ # forge INFERENCE -> INFERENCE
119
+ # forge ASSUMPTION -> ASSUMPTION
120
+ # forge CONSTRAINT -> CONSTRAINT
121
+ # forge UNKNOWN -> UNKNOWN
122
+ # (not named by forge) HYPOTHESIS, REQUIREMENT, DEPENDENCY -> RECOGNIZED
123
+
124
+ FORGE_TIERS = frozenset(
125
+ {"FACT", "OBSERVATION", "CLAIM", "OBFUSCATION", "INFERENCE",
126
+ "ASSUMPTION", "CONSTRAINT", "UNKNOWN"}
127
+ )
128
+
129
+ FORGE_MISSING = frozenset({"HYPOTHESIS", "REQUIREMENT", "DEPENDENCY"})
130
+
131
+
132
+ def forge_mapping_table() -> str:
133
+ """The reconciliation, as a human-readable table."""
134
+ lines = [
135
+ "| field-forge 8-tier | authoritative tag | status |",
136
+ "|--------------------|-------------------|--------|",
137
+ ]
138
+ for tier in sorted(FORGE_TIERS):
139
+ lines.append(f"| {tier} | {tier} | 1:1 map |")
140
+ for tier in sorted(FORGE_MISSING):
141
+ lines.append(f"| (not named) | {tier} | recognized |")
142
+ return "\n".join(lines)
143
+
144
+
145
+ def check_matches_engine() -> str:
146
+ """Assert this taxonomy matches adversarial_ifs' — guard against forks.
147
+
148
+ Returns "matches" on success. Skips (returns "skipped: adversarial_ifs
149
+ not importable") when the sibling package isn't on the path — the
150
+ vendored copy above remains authoritative either way.
151
+ """
152
+ try:
153
+ import adversarial_ifs.evidence as engine_evidence
154
+ except ImportError:
155
+ return "skipped: adversarial_ifs not importable"
156
+ ours = set(TAXONOMY)
157
+ theirs = set(engine_evidence.TAXONOMY)
158
+ if ours != theirs:
159
+ raise EvidenceError(
160
+ "Taxonomy fork detected: fairit and adversarial_ifs "
161
+ f"disagree. Only here: {sorted(ours - theirs)}; "
162
+ f"only in engine: {sorted(theirs - ours)}. "
163
+ "Reconcile before shipping."
164
+ )
165
+ for tag in ours:
166
+ if TAXONOMY[tag] != engine_evidence.TAXONOMY[tag]:
167
+ raise EvidenceError(
168
+ f"Taxonomy fork detected: definition of {tag} differs "
169
+ "between fairit and adversarial_ifs. Reconcile "
170
+ "before shipping."
171
+ )
172
+ return "matches"
173
+
174
+
175
+ def tag_list() -> list[str]:
176
+ """All valid tags, in canonical order."""
177
+ return list(TAXONOMY)
fairit/ground.py ADDED
@@ -0,0 +1,123 @@
1
+ """Ground mapping — the pre-interrogation audit of situational state.
2
+
3
+ Objective: establish exactly what is the case WITHOUT forcing narrative
4
+ resolution. Three outputs:
5
+
6
+ - Held tensions: opposing facts stated as parallel, non-subordinating
7
+ sentences ("X is true. Y is also true."). Never "X, but Y" — the
8
+ subordinating conjunction quietly privileges one side, which is a
9
+ resolution smuggled in as grammar.
10
+ - Time-indexed person-state sequence: people change across time; map the
11
+ states, never flatten a person to one characterization.
12
+ - Corrections typed as detail vs core-claim: fixing a date is a detail;
13
+ changing what was claimed to have happened is a core-claim change —
14
+ and the latter is the signal.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+
22
+ # Subordinating conjunctions that privilege one side of a tension.
23
+ # "X is true. Y is also true." stays. "X is true, but Y" is rejected —
24
+ # the "but" resolves the contradiction by demoting X.
25
+ SUBORDINATORS = (
26
+ "but", "however", "although", "though", "yet", "whereas",
27
+ "nevertheless", "nonetheless", "which means", "in other words",
28
+ "so really", "that is to say",
29
+ )
30
+
31
+ _SUB_RE = re.compile(
32
+ r"\b(" + "|".join(re.escape(s) for s in SUBORDINATORS) + r")\b",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ class GroundError(ValueError):
38
+ """Raised when mapped ground breaks the audit rules."""
39
+
40
+
41
+ def find_subordinators(text: str) -> list[str]:
42
+ """Return the subordinating conjunctions found in the text."""
43
+ return sorted({m.group(0).lower() for m in _SUB_RE.finditer(text)})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class HeldTension:
48
+ """One pair of opposing facts, held live as parallel sentences."""
49
+
50
+ text: str
51
+
52
+ def __post_init__(self) -> None:
53
+ if not self.text.strip():
54
+ raise GroundError("Held tension must not be empty.")
55
+ bad = find_subordinators(self.text)
56
+ if bad:
57
+ raise GroundError(
58
+ f"Held tension uses subordinating language {bad}: "
59
+ f"{self.text!r}. Restate as parallel non-subordinating "
60
+ "sentences — 'X is true. Y is also true.'"
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class PersonState:
66
+ """One person, one moment, one observed state. Time-indexed."""
67
+
68
+ when: str
69
+ person: str
70
+ state: str
71
+
72
+ def __post_init__(self) -> None:
73
+ for label, value in (("when", self.when), ("person", self.person),
74
+ ("state", self.state)):
75
+ if not value.strip():
76
+ raise GroundError(f"PersonState.{label} must not be empty.")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Correction:
81
+ """A correction typed by what changed.
82
+
83
+ DETAIL: the facts stay the same, a detail was fixed (dates, names,
84
+ sequence order). CORE-CLAIM: what was claimed to have happened
85
+ changed. Core-claim changes are the signal — they mark where the
86
+ account itself shifted.
87
+ """
88
+
89
+ kind: str # "detail" | "core-claim"
90
+ text: str
91
+
92
+ def __post_init__(self) -> None:
93
+ if self.kind not in ("detail", "core-claim"):
94
+ raise GroundError(
95
+ f"Correction kind must be 'detail' or 'core-claim', got {self.kind!r}."
96
+ )
97
+ if not self.text.strip():
98
+ raise GroundError("Correction text must not be empty.")
99
+
100
+
101
+ @dataclass
102
+ class GroundMap:
103
+ """The audited ground: tensions held, states sequenced, corrections typed."""
104
+
105
+ tensions: list[HeldTension] = field(default_factory=list)
106
+ person_states: list[PersonState] = field(default_factory=list)
107
+ corrections: list[Correction] = field(default_factory=list)
108
+
109
+ def core_claim_changes(self) -> list[Correction]:
110
+ """The signal: corrections that changed the account itself."""
111
+ return [c for c in self.corrections if c.kind == "core-claim"]
112
+
113
+ def render(self) -> str:
114
+ lines = ["## Audited ground"]
115
+ for t in self.tensions:
116
+ lines.append(f"- Held tension: {t.text}")
117
+ for p in self.person_states:
118
+ lines.append(f"- State [{p.when}] {p.person}: {p.state}")
119
+ for c in self.corrections:
120
+ lines.append(f"- Correction ({c.kind}): {c.text}")
121
+ if not (self.tensions or self.person_states or self.corrections):
122
+ lines.append("- (no ground mapped)")
123
+ return "\n".join(lines)
fairit/hypotheses.py ADDED
@@ -0,0 +1,218 @@
1
+ """Competing-hypothesis testing — the shared core of this package.
2
+
3
+ Every candidate evasion (shield vocabulary, fogging, DARVO, passive-voice
4
+ responsibility-dodging, whatever dialect it arrives in) is tested as
5
+ competing hypotheses instead of pre-judged:
6
+
7
+ - H_OBFUSCATION: the language functions as armor — deflecting
8
+ responsibility, buying status, or changing the topic.
9
+ - H_GENUINE: the language is a sincere attempt, correlated with
10
+ accountability.
11
+ - H_BOTH: genuine effort that is also conveniently shielding. Both can be
12
+ true — and this is the common case.
13
+ - INSUFFICIENT_EVIDENCE: the honest finding when the evidence cannot
14
+ distinguish the hypotheses. Not a failure.
15
+
16
+ The load-bearing rule, enforced in code: a hypothesis wins ONLY on
17
+ **discriminating evidence** — observations that would differ between the
18
+ hypotheses. Category membership ("this is therapy-speak", "this is
19
+ corporate language") is never evidence. A record that tries to convict
20
+ on category alone is rejected.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+
27
+ H_OBFUSCATION = "H-obfuscation"
28
+ H_GENUINE = "H-genuine"
29
+ H_BOTH = "H-both"
30
+ INSUFFICIENT_EVIDENCE = "insufficient-evidence"
31
+
32
+ VERDICTS = (H_OBFUSCATION, H_GENUINE, H_BOTH, INSUFFICIENT_EVIDENCE)
33
+ DIRECTIONED = (H_OBFUSCATION, H_GENUINE, H_BOTH)
34
+
35
+ SUPPORT_STATUSES = ("supported", "contested", "unsupported")
36
+
37
+
38
+ class HypothesisError(ValueError):
39
+ """Raised when a hypothesis record breaks the testing rules."""
40
+
41
+
42
+ class CategoryPresumptionError(HypothesisError):
43
+ """Raised when a record tries to convict on category membership alone.
44
+
45
+ The old probe (v1) pre-judged whole categories of language as
46
+ obfuscation. This error is that failure mode, made impossible in code:
47
+ "therapy-speak" by itself, with no observation attached, is not
48
+ discriminating evidence of anything.
49
+ """
50
+
51
+
52
+ # Category labels that may contextualize a record but may never stand
53
+ # in for evidence. Lowercase, punctuation-stripped for matching.
54
+ CATEGORY_LABELS = frozenset({
55
+ "therapy speak", "therapy-speak", "therapyspeak", "self regulation",
56
+ "self-regulation", "selfregulation",
57
+ "corporate speak", "corporate-speak", "corporatespeak",
58
+ "bureaucratic", "bureaucratese", "legalese", "wellness speak",
59
+ "wellness-speak", "hr speak", "hr-speak",
60
+ })
61
+
62
+
63
+ def looks_category_only(text: str) -> bool:
64
+ """True when an evidence item names a category and observes nothing.
65
+
66
+ Heuristic, deliberately conservative: it catches the bare label
67
+ ("therapy-speak") and the label plus filler words ("uses
68
+ therapy-speak", "sounds like corporate speak"). Anything with a real
69
+ observation attached passes — the prose-level judgment ("is this
70
+ actually discriminating?") stays with the operator, and this check
71
+ exists to make the *failure mode* impossible, not to certify the
72
+ good cases.
73
+ """
74
+ import re
75
+ import string
76
+
77
+ cleaned = text.strip().lower().translate(
78
+ str.maketrans(string.punctuation, " " * len(string.punctuation))
79
+ )
80
+ cleaned = " ".join(cleaned.split())
81
+ if cleaned in CATEGORY_LABELS:
82
+ return True
83
+ for label in CATEGORY_LABELS:
84
+ rest = cleaned.replace(label, " ", 1)
85
+ filler = rest.split()
86
+ # e.g. "uses therapy-speak" / "sounds like corporate speak"
87
+ if len(filler) <= 2 and all(
88
+ w in {"uses", "using", "use", "sounds", "like", "is", "very", "kind",
89
+ "of", "sort", "type", "just", "simply"} for w in filler
90
+ ):
91
+ return True
92
+ if re.fullmatch(r"(uses|using|is|sounds like)\s*", rest + " ") or not filler:
93
+ return True
94
+ return False
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class DiscriminatingEvidence:
99
+ """One observation that would differ between the hypotheses.
100
+
101
+ `favors` names which hypothesis this observation supports, or
102
+ "neutral" for an observation that constrains the case without
103
+ favoring a side (e.g. a timeline fact both hypotheses must fit).
104
+ """
105
+
106
+ text: str
107
+ favors: str = "neutral"
108
+
109
+ def __post_init__(self) -> None:
110
+ if not self.text.strip():
111
+ raise HypothesisError("Evidence item text must not be empty.")
112
+ if self.favors not in DIRECTIONED + ("neutral",):
113
+ raise HypothesisError(f"Bad evidence direction: {self.favors!r}.")
114
+ if looks_category_only(self.text):
115
+ raise CategoryPresumptionError(
116
+ f"Category membership is not evidence: {self.text!r}. "
117
+ "Attach an observation — what was said, done, or omitted, "
118
+ "and what would differ between the hypotheses."
119
+ )
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Mechanism:
124
+ """One candidate tactic, held as a hypothesis with a support status."""
125
+
126
+ name: str
127
+ status: str # supported | contested | unsupported
128
+
129
+ def __post_init__(self) -> None:
130
+ if self.status not in SUPPORT_STATUSES:
131
+ raise HypothesisError(f"Bad mechanism status: {self.status!r}.")
132
+ if not self.name.strip():
133
+ raise HypothesisError("Mechanism name must not be empty.")
134
+
135
+
136
+ @dataclass
137
+ class HypothesisRecord:
138
+ """The result of testing one candidate evasion.
139
+
140
+ - candidate: the responsibility that may be dodged, or the flagged
141
+ language instance — specific, not a category.
142
+ - verdict: the best-supported hypothesis, or INSUFFICIENT_EVIDENCE.
143
+ - evidence: observations that separate the hypotheses.
144
+ - mechanisms: candidate tactics, each with a support status.
145
+ - remaining_questions: what evidence would resolve the open
146
+ hypotheses. Required when the verdict is INSUFFICIENT_EVIDENCE.
147
+ """
148
+
149
+ candidate: str
150
+ verdict: str
151
+ evidence: list[DiscriminatingEvidence] = field(default_factory=list)
152
+ mechanisms: list[Mechanism] = field(default_factory=list)
153
+ remaining_questions: list[str] = field(default_factory=list)
154
+
155
+ def __post_init__(self) -> None:
156
+ if self.verdict not in VERDICTS:
157
+ raise HypothesisError(f"Bad verdict: {self.verdict!r}.")
158
+ if not self.candidate.strip():
159
+ raise HypothesisError("Candidate must name the specific evasion or language instance.")
160
+ self._require_discriminating_evidence()
161
+
162
+ def _favors(self, direction: str) -> list[DiscriminatingEvidence]:
163
+ return [e for e in self.evidence if e.favors == direction]
164
+
165
+ def _require_discriminating_evidence(self) -> None:
166
+ if self.verdict == H_OBFUSCATION and not self._favors(H_OBFUSCATION):
167
+ raise HypothesisError(
168
+ "H-obfuscation requires at least one evidence item favoring "
169
+ "H-obfuscation. Category membership does not count."
170
+ )
171
+ if self.verdict == H_GENUINE and not self._favors(H_GENUINE):
172
+ raise HypothesisError(
173
+ "H-genuine requires at least one evidence item favoring H-genuine."
174
+ )
175
+ if self.verdict == H_BOTH and not (
176
+ self._favors(H_OBFUSCATION) and self._favors(H_GENUINE)
177
+ ):
178
+ raise HypothesisError(
179
+ "H-both requires discriminating evidence on BOTH sides — "
180
+ "at least one item favoring H-obfuscation and one favoring "
181
+ "H-genuine. Otherwise the honest verdict is one of the "
182
+ "other three."
183
+ )
184
+ if self.verdict == INSUFFICIENT_EVIDENCE and not self.remaining_questions:
185
+ raise HypothesisError(
186
+ "An insufficient-evidence verdict must name what evidence "
187
+ "would resolve the open hypotheses."
188
+ )
189
+
190
+ def supports_obfuscation(self) -> bool:
191
+ """True only for a supported H-obfuscation verdict.
192
+
193
+ This is the single gate for the OBFUSCATION evidence tag.
194
+ Everything else — including suspected-but-untested material —
195
+ stays CLAIM with a hypothesis marker.
196
+ """
197
+ return self.verdict == H_OBFUSCATION and (
198
+ not self.mechanisms
199
+ or any(m.status == "supported" for m in self.mechanisms)
200
+ )
201
+
202
+ def summary(self) -> str:
203
+ lines = [
204
+ f"Candidate evasion: {self.candidate}",
205
+ f"Best-supported hypothesis: {self.verdict}",
206
+ "Discriminating evidence:",
207
+ ]
208
+ lines.extend(
209
+ f" - [{e.favors}] {e.text}" for e in self.evidence
210
+ ) or lines.append(" (none)")
211
+ lines.append("Key mechanisms:")
212
+ lines.extend(
213
+ f" - {m.name} [{m.status}]" for m in self.mechanisms
214
+ ) or lines.append(" (none)")
215
+ if self.remaining_questions:
216
+ lines.append("Remaining questions:")
217
+ lines.extend(f" - {q}" for q in self.remaining_questions)
218
+ return "\n".join(lines)
fairit/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
+ }
fairit/overlay.py ADDED
@@ -0,0 +1,184 @@
1
+ """The evasion-probe protocol: test, never presume.
2
+
3
+ This is the front-facing form of the two skills being converted together:
4
+
5
+ - responsibility-obfuscation-probe: every candidate evasion becomes
6
+ competing hypotheses tested against discriminating evidence.
7
+ - field-forge's probe stage: the same protocol, generalized past
8
+ therapy-speak to any shield dialect (corporate, bureaucratic).
9
+
10
+ One implementation, two consumers — the overlap the assessment flagged
11
+ is structural, and this module is the single place the protocol lives.
12
+
13
+ The probe's outputs:
14
+ - ObfuscatedObject: the responsibility that MAY be dodged, with a
15
+ support status (supported / contested / unsupported) — never a verdict.
16
+ - JargonFlag: one instance of possibly-evasive vocabulary, run through
17
+ H-obfuscation / H-genuine / H-both against discriminating evidence.
18
+ - The evidence tag for a record: OBFUSCATION only when H-obfuscation is
19
+ supported by discriminating evidence; otherwise CLAIM with a
20
+ hypothesis marker. Suspected-but-untested material is never upgraded
21
+ on category membership alone.
22
+
23
+ The probe never collides, adjudicates, refines, or extracts surprise.
24
+ Its output feeds whatever comes next (the interrogation engine's
25
+ Diverge phase, or the operator's own judgment) as hypothesis-tagged
26
+ material.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+
33
+ from .dialects import DIALECTS, detect_markers
34
+ from .evidence import OBFUSCATION, CLAIM, normalize_tag
35
+ from .ledger import Ledger
36
+ from .hypotheses import (
37
+ H_OBFUSCATION,
38
+ H_GENUINE,
39
+ H_BOTH,
40
+ INSUFFICIENT_EVIDENCE,
41
+ SUPPORT_STATUSES,
42
+ DiscriminatingEvidence,
43
+ HypothesisRecord,
44
+ Mechanism,
45
+ )
46
+
47
+
48
+ class ProbeError(ValueError):
49
+ """Raised when probe input breaks the protocol."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class JargonFlag:
54
+ """One instance of possibly-evasive vocabulary, under test.
55
+
56
+ `dialect` names where the shield vocabulary came from; `record`
57
+ carries the actual hypothesis test. A flag with no test attached is
58
+ incomplete — detection without testing is exactly the v1 failure
59
+ mode this package exists to prevent.
60
+ """
61
+
62
+ phrase: str
63
+ dialect: str
64
+ record: HypothesisRecord
65
+
66
+ def __post_init__(self) -> None:
67
+ if not self.phrase.strip():
68
+ raise ProbeError("Jargon flag needs the flagged phrase.")
69
+ if self.dialect not in DIALECTS:
70
+ raise ProbeError(
71
+ f"Unknown dialect {self.dialect!r}. Known: {', '.join(DIALECTS)}."
72
+ )
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ObfuscatedObject:
77
+ """The responsibility that may be dodged — a hypothesis, not a verdict."""
78
+
79
+ candidate: str
80
+ support: str # supported | contested | unsupported
81
+ apparent_function: str = "" # what the evasion would achieve if real
82
+
83
+ def __post_init__(self) -> None:
84
+ if not self.candidate.strip():
85
+ raise ProbeError("Obfuscated-object candidate must not be empty.")
86
+ if self.support not in SUPPORT_STATUSES:
87
+ raise ProbeError(f"Bad support status: {self.support!r}.")
88
+
89
+
90
+ def run_jargon_test(
91
+ phrase: str,
92
+ dialect: str,
93
+ evidence_for_obfuscation: str,
94
+ evidence_for_genuine: str,
95
+ *,
96
+ mechanisms: list[Mechanism] | None = None,
97
+ remaining_questions: list[str] | None = None,
98
+ ) -> JargonFlag:
99
+ """Run one jargon instance through the hypothesis protocol.
100
+
101
+ Both hypotheses are tested against discriminating evidence. When the
102
+ evidence favors one side, that hypothesis wins; when it favors both,
103
+ the honest verdict is H-both; when it can't distinguish them, the
104
+ honest verdict is insufficient-evidence — never a verdict.
105
+ """
106
+ has_obf = bool(evidence_for_obfuscation.strip())
107
+ has_gen = bool(evidence_for_genuine.strip())
108
+ if has_obf and not has_gen:
109
+ verdict = H_OBFUSCATION
110
+ evidence = [DiscriminatingEvidence(evidence_for_obfuscation, H_OBFUSCATION)]
111
+ elif has_gen and not has_obf:
112
+ verdict = H_GENUINE
113
+ evidence = [DiscriminatingEvidence(evidence_for_genuine, H_GENUINE)]
114
+ elif has_obf and has_gen:
115
+ verdict = H_BOTH
116
+ evidence = [
117
+ DiscriminatingEvidence(evidence_for_obfuscation, H_OBFUSCATION),
118
+ DiscriminatingEvidence(evidence_for_genuine, H_GENUINE),
119
+ ]
120
+ else:
121
+ verdict = INSUFFICIENT_EVIDENCE
122
+ evidence = []
123
+ if not remaining_questions:
124
+ remaining_questions = [
125
+ "What observation would distinguish H-obfuscation from "
126
+ "H-genuine here? (e.g. does the language appear only under "
127
+ "pressure? is it followed by accountability or topic change?)"
128
+ ]
129
+ record = HypothesisRecord(
130
+ candidate=f"the phrase {phrase!r} may function as evasion",
131
+ verdict=verdict,
132
+ evidence=evidence,
133
+ mechanisms=list(mechanisms or []),
134
+ remaining_questions=list(remaining_questions or []),
135
+ )
136
+ Ledger("fairit").record(
137
+ "jargon_test",
138
+ {
139
+ "dialect": dialect,
140
+ "verdict": verdict,
141
+ "mechanisms": len(mechanisms or []),
142
+ "evidence_items": len(evidence),
143
+ },
144
+ input_text=phrase,
145
+ )
146
+ return JargonFlag(phrase=phrase, dialect=dialect, record=record)
147
+
148
+
149
+ def scan_and_test(
150
+ text: str,
151
+ evidence: dict[str, tuple[str, str]],
152
+ ) -> list[JargonFlag]:
153
+ """Detect shield vocabulary in text and run each hit through the protocol.
154
+
155
+ `evidence` maps each detected phrase to
156
+ (evidence_for_obfuscation, evidence_for_genuine). A hit with no
157
+ supplied evidence gets the insufficient-evidence verdict — the probe
158
+ refuses to test-free convict OR exonerate on detection alone.
159
+ """
160
+ flags: list[JargonFlag] = []
161
+ for dialect, phrase in detect_markers(text):
162
+ for_obf, for_gen = evidence.get(phrase, ("", ""))
163
+ flags.append(
164
+ run_jargon_test(
165
+ phrase, dialect, for_obf, for_gen,
166
+ remaining_questions=[
167
+ "What evidence would distinguish obfuscation from "
168
+ "genuine use here?"
169
+ ] if not (for_obf.strip() or for_gen.strip()) else [],
170
+ )
171
+ )
172
+ return flags
173
+
174
+
175
+ def evidence_tag_for(record: HypothesisRecord, *, probe_ran: bool = True) -> str:
176
+ """The evidence tag a record earns.
177
+
178
+ OBFUSCATION only when the probe ran and H-obfuscation is supported
179
+ by discriminating evidence. Everything else stays CLAIM — with a
180
+ hypothesis marker, so downstream consumers know a test was run.
181
+ """
182
+ if probe_ran and record.supports_obfuscation():
183
+ return normalize_tag(OBFUSCATION, probe_ran=True)
184
+ return normalize_tag(CLAIM)
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: fairit
3
+ Version: 1.0.0
4
+ Summary: Ground mapping and evasion-hypothesis testing: hold the state, test the language, hand off the seeds. Never presumes evasion.
5
+ Author: Ryan Wesley Hitchcock
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ohpersonperson/frontface
8
+ Keywords: hypothesis-testing,evasion,audit,interrogation,evidence
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # fairit — ground mapping and evasion-hypothesis testing
18
+
19
+ Tests whether evasive-looking communication is actually evading
20
+ anything — and if so, what. It never presumes evasion. Three stages:
21
+
22
+ 1. **Ground mapping.** Establish the exact situational state without
23
+ forcing narrative resolution: opposing facts as parallel,
24
+ non-subordinating sentences ("X is true. Y is also true." — never
25
+ "X, but Y"), people as time-indexed states, every correction typed
26
+ as detail vs core-claim.
27
+ 2. **Evasion-hypothesis testing.** Every candidate evasion — therapy-speak,
28
+ corporate-speak, bureaucratic passive voice, fogging, DARVO,
29
+ projection — becomes competing hypotheses: **H-obfuscation /
30
+ H-genuine / H-both**, tested against discriminating evidence.
31
+ 3. **Handoff.** 2–3 seed frames plus the audited ground, handed to
32
+ whatever comes next. Then the package stands down.
33
+
34
+ The one rule everything else serves: **a hypothesis wins only on
35
+ discriminating evidence** — observations that would differ between the
36
+ hypotheses. Category membership ("this is therapy-speak") is never
37
+ evidence. "Insufficient evidence to distinguish" is an honest finding,
38
+ not a failure.
39
+
40
+ No prerequisites. The mechanism is the whole product. No model, no
41
+ network — this is pure machinery. The operator (human or model) supplies
42
+ the evidence; the library enforces the testing discipline.
43
+
44
+ *(Lineage: the front-facing conversion of two skills from Ryan's
45
+ skill-suite, MIT — `responsibility-obfuscation-probe` and `field-forge`,
46
+ converted as one unit because they share one hypothesis-testing core.
47
+ What changed in the conversion is documented in CHANGELOG.md.)*
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ from fairit import scan_and_test, evidence_tag_for
53
+
54
+ text = "Mistakes were made in the scoping phase. We're going to circle back."
55
+ flags = scan_and_test(text, {
56
+ "mistakes were made": (
57
+ "the passive phrasing names no actor; internal emails name the PM "
58
+ "who cut scoping short — the actor is known",
59
+ "",
60
+ ),
61
+ "circle back": (
62
+ "used to end the paragraph about the miss without giving a new date",
63
+ "",
64
+ ),
65
+ })
66
+ for flag in flags:
67
+ print(flag.phrase, "->", flag.record.verdict, "->", evidence_tag_for(flag.record))
68
+ # mistakes were made -> H-obfuscation -> OBFUSCATION
69
+ # circle back -> H-obfuscation -> OBFUSCATION
70
+ ```
71
+
72
+ Full cycle demo (ground → probe → brief), no model needed:
73
+
74
+ ```bash
75
+ cd probe-forge
76
+ PYTHONPATH=src python3 examples/run_cycle.py
77
+ ```
78
+
79
+ ## What it does
80
+
81
+ **Ground mapping** (`ground.py`) — the pre-interrogation audit:
82
+
83
+ - Held tensions must be parallel non-subordinating sentences. The
84
+ subordinating-conjunction scan is in code: "promised a refund, *but*
85
+ none arrived" is rejected; "promised a refund. None arrived." passes.
86
+ - People are time-indexed states, never one flat characterization.
87
+ - Corrections are typed detail vs core-claim. Core-claim changes — the
88
+ account itself shifting — are the signal, and the library surfaces
89
+ them.
90
+
91
+ **Hypothesis testing** (`hypotheses.py`) — the shared core:
92
+
93
+ - Four verdicts: H-obfuscation, H-genuine, H-both, insufficient-evidence.
94
+ - Code-enforced rules: H-obfuscation needs ≥1 evidence item favoring it;
95
+ H-both needs evidence on *both* sides; insufficient-evidence must name
96
+ what would resolve the open hypotheses.
97
+ - The category-presumption check: evidence that only names a category
98
+ ("therapy-speak", "uses corporate speak") is rejected with
99
+ `CategoryPresumptionError`. This is the old probe's failure mode
100
+ (pre-judging therapy-speak as armor), made impossible in code.
101
+
102
+ **Probe protocol** (`overlay.py`) — jargon in three dialects (therapy,
103
+ corporate, bureaucratic; see `dialects.py`):
104
+
105
+ - Detection produces *candidates*, never verdicts. A detected phrase with
106
+ no evidence gets insufficient-evidence — the probe refuses to convict
107
+ *or* exonerate on detection alone.
108
+ - The evidence tag: **OBFUSCATION** only when H-obfuscation is supported
109
+ by discriminating evidence. Everything else stays **CLAIM** with a
110
+ hypothesis marker. Suspected-but-untested material is never upgraded
111
+ on category membership alone.
112
+
113
+ **Audit brief** (`brief.py`) — the output artifact with frontmatter
114
+ (`artifact:`, `date:`, `protocol:`, `lifecycle:`), Markdown
115
+ render/parse, and the stand-down rule enforced in code: the brief must
116
+ never contain collision, adjudication, refinement, or surprise sections.
117
+ Those belong to the interrogation engine. If the engine isn't running,
118
+ the brief is reported and the work stops.
119
+
120
+ **Evidence taxonomy** (`evidence.py`) — one authoritative mapping.
121
+ field-forge shipped an 8-tier list; the interrogation engine uses 11
122
+ tags (10 core + overlay-only OBFUSCATION). The forge's 8 map 1:1 onto
123
+ the engine's; the 3 the forge never named (HYPOTHESIS, REQUIREMENT,
124
+ DEPENDENCY) are documented as recognized. `check_matches_engine()`
125
+ guards against silent forks when the sibling package is importable.
126
+
127
+ ## What's honest about the limits
128
+
129
+ - The library cannot prove a counter-hypothesis is *strong* — the
130
+ evidence-quality judgment stays with the operator. What it makes
131
+ impossible is the failure modes: verdicts without discriminating
132
+ evidence, category-based presumption, silent upgrades to OBFUSCATION,
133
+ subordinated tensions, and the overlay doing the engine's job.
134
+ - The dialog procedure (asking for raw input, precision-forcing
135
+ questions) is an operator protocol, documented in the examples — it
136
+ isn't codeable without a model in the loop, and isn't pretended to be.
137
+
138
+ ## Tests
139
+
140
+ ```bash
141
+ PYTHONPATH=src python3 -m unittest discover -s tests
142
+ ```
143
+
144
+ 55 offline tests: verdict rules, category-presumption rejection (the v1
145
+ regression suite), the OBFUSCATION gate, the subordinator scan,
146
+ correction typing, taxonomy reconciliation, brief round-trip and
147
+ stand-down, dialect detection, and a full ground→probe→brief cycle.
@@ -0,0 +1,13 @@
1
+ fairit/__init__.py,sha256=AI34I3fLf4eVUQJAWDTmN3_dqbVBUV7Jhj_7y_QiDpQ,1869
2
+ fairit/brief.py,sha256=7xKpMgRPGK1XsFo201BpN3me_BIIUg7CfJmMZ6fXodc,4903
3
+ fairit/dialects.py,sha256=yY8S4U28TJ4VK9VyJ2st5DHbk-eBU-06Hz18DiLqg24,2136
4
+ fairit/evidence.py,sha256=NxQhEkinxy96d_KhsFDjTNdNgtuuQPzm0ixRUf4AsGc,6854
5
+ fairit/ground.py,sha256=-U6vh9buouy3s5NTRq0z54gKL3yT0oC0TMP7BA_hFw0,4335
6
+ fairit/hypotheses.py,sha256=RVoOqDpnkK-pBSYdrp3_guUAxF3klC94hS-_vAqBdPI,8749
7
+ fairit/ledger.py,sha256=x8TH2et1Jt_UTzLMn49R8YKEiMMykYTW6UoMz-l8qCU,4245
8
+ fairit/overlay.py,sha256=EgF5AB82gF778e3zZdvOb3WEcmaEoJGnB4rC13NijkY,6713
9
+ fairit-1.0.0.dist-info/licenses/LICENSE,sha256=Zx_XafyZDq7qBexUb7tER9kYlBZ0rCVaAwKuqMCfxLE,1078
10
+ fairit-1.0.0.dist-info/METADATA,sha256=cQ0JYmymqqmFucs45s0HEBnuZbKkEKFqhIp5Wrb0s78,6496
11
+ fairit-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ fairit-1.0.0.dist-info/top_level.txt,sha256=3abimNla0_0zZJ5Cj3UWU_9zUMRQx44En85ySkDCmkg,7
13
+ fairit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Wesley Hitchcock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fairit