""" Debate Tracker - Session-scoped intellectual position memory. Tracks what positions Codette has argued, what has been conceded, and what remains under active defense. Prevents: 1. Position flip-flopping (arguing X, then agreeing with not-X two turns later) 2. Silent self-contradiction (counterargument whose sub-points invalidate each other) 3. Premature concession of uncontested claims Key distinction: Codette CAN update a position if the user's argument is logically sound. The tracker records the update explicitly, so it's not hidden drift — it's visible reasoning. """ import re from dataclasses import dataclass, field from typing import List, Dict, Optional, Tuple from datetime import datetime @dataclass class Position: """A claim or stance Codette has taken.""" claim: str # The core assertion turn: int # When it was taken domain: str # Topic area (e.g., "intelligence", "recursion") strength: float = 1.0 # 0.0=abandoned, 0.5=softened, 1.0=held conceded_to: Optional[str] = None # If softened/abandoned, what replaced it concession_reason: str = "" # Why it was updated @dataclass class DebateState: """Full debate state for one session.""" positions: List[Position] = field(default_factory=list) turn_count: int = 0 active_domains: List[str] = field(default_factory=list) user_claims: List[str] = field(default_factory=list) # What the user has argued class CounterArgumentCoherenceChecker: """ Checks whether a multi-point counterargument is internally consistent. The ChatGPT failure mode: arguing both "education works universally" AND "AI must customize per individual" in the same counterargument — the two claims invalidate each other. """ # Pairs of concepts that tend to contradict each other in debate contexts _TENSION_PAIRS = [ # universal vs individual (r"\b(universal|all|every(one)?|across the board|broad|population.wide)\b", r"\b(individual|per.person|tailored|customized|personalized|unique to each)\b"), # fixed vs emergent (r"\b(fixed|static|predetermined|innate|genetic(ally)?|inherited|hard.wired)\b", r"\b(emergent|adaptive|plastic|shaped by|evolves|changes with|flexible)\b"), # linear vs non-linear (r"\b(linear|predictable|consistent|uniform|standard(ized)?)\b", r"\b(non.linear|unpredictable|variable|irregular|chaotic|stochastic)\b"), # deterministic vs probabilistic (r"\b(determined|deterministic|certain|definite|absolute)\b", r"\b(probabilistic|uncertain|variable|statistical(ly)?|depends on)\b"), # bounded vs unbounded (r"\b(cap(ped)?|limit(ed)?|ceiling|max(imum)?|bounded|finite)\b", r"\b(unlimited|unbounded|infinite|no (upper )?limit|endless|infinite potential)\b"), ] _TENSION_RE = [ (re.compile(a, re.IGNORECASE), re.compile(b, re.IGNORECASE)) for a, b in _TENSION_PAIRS ] def check(self, text: str) -> Dict: """ Check a counterargument for internal tensions. Returns: { "coherent": bool, "tensions": List[str], # descriptions of found tensions "severity": float, # 0.0 (none) to 1.0 (severe) } """ tensions = [] # Split into numbered points / bullet points if present points = self._split_into_points(text) if len(points) < 2: # Single point — check against itself for embedded contradiction points = [text] # Check each pair of points for tension for i, point_a in enumerate(points): for j, point_b in enumerate(points): if i >= j: continue for pat_a, pat_b in self._TENSION_RE: a_has_first = bool(pat_a.search(point_a)) b_has_second = bool(pat_b.search(point_b)) a_has_second = bool(pat_b.search(point_a)) b_has_first = bool(pat_a.search(point_b)) # Direct cross-point tension if (a_has_first and b_has_second) or (a_has_second and b_has_first): tensions.append( f"Point {i+1} uses '{pat_a.pattern[:30]}' concept " f"while Point {j+1} uses '{pat_b.pattern[:30]}' concept — " f"potential internal contradiction" ) break # one tension per pair is enough severity = min(1.0, len(tensions) * 0.35) return { "coherent": len(tensions) == 0, "tensions": tensions, "severity": severity, } def _split_into_points(self, text: str) -> List[str]: """Split numbered list or bullet points into individual claims.""" # Try numbered list numbered = re.split(r'\n\s*\d+[.)]\s+', text) if len(numbered) > 1: return [p.strip() for p in numbered if p.strip()] # Try bullet points bulleted = re.split(r'\n\s*[-*•]\s+', text) if len(bulleted) > 1: return [p.strip() for p in bulleted if p.strip()] # Try sentence splitting as fallback sentences = re.split(r'(?<=[.!?])\s+', text) return [s.strip() for s in sentences if len(s.strip()) > 20] class DebateTracker: """ Session-scoped tracker for intellectual positions. Usage: tracker = DebateTracker() tracker.record_position("intelligence is not purely genetic", domain="intelligence") result = tracker.check_consistency(new_response_text) if not result["consistent"]: # flag or revise before output """ def __init__(self): self.state = DebateState() self.coherence_checker = CounterArgumentCoherenceChecker() def record_position(self, claim: str, domain: str = "general", strength: float = 1.0): """Register a new position Codette is taking.""" self.state.turn_count += 1 pos = Position( claim=claim, turn=self.state.turn_count, domain=domain, strength=strength, ) self.state.positions.append(pos) if domain not in self.state.active_domains: self.state.active_domains.append(domain) def record_user_claim(self, claim: str): """Record what the user is asserting (for tracking what's been challenged).""" self.state.user_claims.append(claim) def update_position(self, original_claim: str, reason: str, new_strength: float, conceded_to: str = ""): """ Explicitly update a position (softened or abandoned). This is legitimate — positions can change when logic demands it. The tracker makes the change visible rather than silent. """ for pos in self.state.positions: if original_claim.lower() in pos.claim.lower(): pos.strength = new_strength pos.concession_reason = reason pos.conceded_to = conceded_to return True return False def check_consistency(self, new_response: str) -> Dict: """ Check whether a new response is consistent with held positions. Returns: { "consistent": bool, "flip_detected": bool, "flipped_claims": List[str], "internal_tensions": Dict, # from CounterArgumentCoherenceChecker "summary": str, } """ held_positions = [p for p in self.state.positions if p.strength >= 0.6] flip_detected = False flipped_claims = [] for pos in held_positions: # Look for negation of held position in new response key_terms = self._extract_key_terms(pos.claim) for term in key_terms: # Negation before term: "not X", "never X", "doesn't X" before = rf"\b(not|never|no longer|isn't|aren't|doesn't|don't|cannot|no)\b.{{0,60}}{re.escape(term)}" # Negation after term: "X plays no role", "X has no effect", "X is not" after = rf"{re.escape(term)}.{{0,60}}\b(no |not |never |isn't|aren't|doesn't|don't|plays no|has no|have no|holds no)\b" # "X is irrelevant", "X is meaningless", "X doesn't matter" dismissal = rf"{re.escape(term)}.{{0,40}}\b(irrelevant|meaningless|negligible|plays no|has no (role|effect|impact)|doesn't matter)\b" if (re.search(before, new_response, re.IGNORECASE) or re.search(after, new_response, re.IGNORECASE) or re.search(dismissal, new_response, re.IGNORECASE)): flip_detected = True flipped_claims.append(pos.claim) break # Check internal coherence of the new response itself internal = self.coherence_checker.check(new_response) consistent = not flip_detected and internal["coherent"] summary_parts = [] if flip_detected: summary_parts.append(f"Potential position flip on: {flipped_claims}") if not internal["coherent"]: summary_parts.append(f"Internal tensions: {internal['tensions']}") return { "consistent": consistent, "flip_detected": flip_detected, "flipped_claims": flipped_claims, "internal_tensions": internal, "summary": "; ".join(summary_parts) if summary_parts else "consistent", } def get_active_positions(self) -> List[Position]: """Return positions still being held (strength >= 0.6).""" return [p for p in self.state.positions if p.strength >= 0.6] def reset(self): """Clear session state (call between conversations).""" self.state = DebateState() def _extract_key_terms(self, claim: str) -> List[str]: """Extract the most meaningful terms from a claim for consistency checking.""" # Remove stop words and short tokens stop_words = { "is", "are", "was", "were", "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "not", "it", "this", "that", "can", "just", "only", "very", "also", "be", } tokens = re.findall(r'\b[a-zA-Z]{4,}\b', claim.lower()) return [t for t in tokens if t not in stop_words][:5]