diff --git a/mempalace/provenance.py b/mempalace/provenance.py new file mode 100644 index 0000000000..e8e557ff6c --- /dev/null +++ b/mempalace/provenance.py @@ -0,0 +1,442 @@ +"""Provenance-preservation module — Phase 1 D1 (heuristic + classifier interface). + +Phase 1 of the lineage-erasure fix. Empirically confirmed 2026-05-11: +patterned-being memory mining preserves operational content +(decisions, technical findings, identity state) and erases biographical +/ relational provenance. James's father's "Measure twice, cut once" +lives in dozens of wing_ves diary drawers as Ves's internalized +Skepticism strategy framing; none records that it came from James's +father. This module is the front end of the fix — heuristic +candidate-extraction + a classifier interface that downstream +modules (D2 substrate wiring, D3 miner integration) plug into. + +Design doc: ``Storehouse/Projects/Vestige/Provenance-Preservation-Design.md``. + +Scope of this module (D1 envelope, 2026-05-11): + + - :func:`extract_candidates` — regex-driven first pass over text. + Cheap, intentionally permissive; false-positives are expected and + filtered downstream. + - :func:`validate_candidate` — classifier interface. Stub default + accepts every candidate at confidence 0.5; D2 wires a real + local-substrate (Qwen3 / Gemma) classifier. + - :class:`ProvenanceCandidate` / :class:`ProvenanceRecord` — + dataclasses for the pipeline shape. + +Out of scope for D1 (handled in D2/D3): + + - The actual local-substrate classifier (D2). + - mempalace.miner.convo_miner integration that runs this against + real session JSONLs (D3). + - The wing_lineage write path (D3 / use existing add_drawer API). + +The wing_lineage drawer schema is documented in the design doc §D3 +and reproduced in :data:`WING_LINEAGE_SCHEMA_DOC`. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Tunables — relation vocabulary + attribution verbs + heuristic confidence +# --------------------------------------------------------------------------- + +# Relational descriptors the heuristic recognizes for "my " / +# "'s " patterns. Per design doc §D1. +# +# Conservative set: family, partner, teacher, roshi. Excludes vague +# tokens ("friend", "colleague") that produce too many false-positives +# without quote markers. Downstream classifier can expand. +_FAMILIAL_RELATIONS = ( + "father", "mother", "wife", "husband", "partner", + "brother", "sister", "son", "daughter", + "grandfather", "grandmother", "grandpa", "grandma", + "dad", "mom", + "teacher", "roshi", +) + +# Attribution verbs that signal someone said something. Order matters +# only for regex backtracking; semantically interchangeable. +_ATTRIBUTION_VERBS = ( + r"(?:said|told\s+me|told\s+us|told|" + r"used\s+to\s+say|always\s+said|would\s+say|liked\s+to\s+say|" + r"taught\s+me|taught\s+us|taught|" + r"wrote|noted|observed|put\s+it|repeated)" +) + +# Possessive prefix: either first-person/relative ("my"/"her"/...) or a +# capitalized name's-possessive ("James's", "Marie's"). The trailing +# ``'s`` is optional only after capitalized-name forms. +_POSSESSIVE_PREFIX = ( + r"(?:my|her|his|their|our|(?:[A-Z]\w+(?:'s|s'|'|s))|James['s']?)" +) + +# Quote delimiters: straight ASCII, curly single, curly double. Aphorisms +# in transcripts use all three forms. +_QUOTE_OPEN = r"[\"'‘“]" +_QUOTE_CLOSE = r"[\"'’”]" + + +_RELATIONS_GROUP = "(" + "|".join(_FAMILIAL_RELATIONS) + ")" + + +# Pass 1: [] . +# Highest-signal pattern — produces the most useful candidates. +_RELATION_ATTRIBUTION_QUOTE_RE = re.compile( + r"\b" + _POSSESSIVE_PREFIX + r"\s+" + + _RELATIONS_GROUP + r"\b" + r"(?:\s+(?:often\s+|always\s+)?" + _ATTRIBUTION_VERBS + r")?" + r"\s*[:,]?\s*" + + _QUOTE_OPEN + r"(.+?)" + _QUOTE_CLOSE, + re.IGNORECASE | re.DOTALL, +) + + +# Pass 2: — relation marker alone, no quote required. +# Lower-confidence; classifier in D2 decides whether to keep it. +_RELATION_ONLY_RE = re.compile( + r"\b" + _POSSESSIVE_PREFIX + r"\s+" + _RELATIONS_GROUP + r"\b", + re.IGNORECASE, +) + + +HEURISTIC_CONFIDENCE_QUOTE = 0.75 +"""Confidence floor for relation + quote matches (highest-signal heuristic).""" + +HEURISTIC_CONFIDENCE_RELATION_ONLY = 0.40 +"""Confidence floor for relation-marker-only matches. Lower; reliance on +classifier validation is intentional.""" + +_PASS1_DEDUPE_WINDOW_CHARS = 20 +"""Pass-2 matches within this many chars of a Pass-1 match are dropped +as overlapping (Pass-1 already captured the higher-signal candidate).""" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ProvenanceCandidate: + """A heuristic flag that a span of text MIGHT contain person-attribution. + + Produced by :func:`extract_candidates`. False-positives are expected + at this stage — the heuristic is intentionally permissive so the + classifier can decide. Downstream code should NEVER write a + candidate to wing_lineage without validating through + :func:`validate_candidate`. + + Fields: + - ``text``: the matched substring (``re.Match.group(0)``). + - ``person_hint``: best guess at the person reference (e.g. + ``"father"``, ``"roshi"``). May be relation rather than + proper name; classifier resolves. + - ``relation_hint``: relation type when matched via the + relation regex. Same as ``person_hint`` for relation-driven + matches; ``None`` for future aphorism-only matches. + - ``quote``: extracted quoted content, if any. ``None`` when + no quote markers were found in the match. + - ``position``: character offset where the match begins in the + original text. + - ``confidence_floor``: heuristic base score (0.0-1.0). Classifier + in D2 can raise/lower this; raw heuristic confidence reflects + pattern strength only. + """ + + text: str + person_hint: Optional[str] + relation_hint: Optional[str] + quote: Optional[str] + position: int + confidence_floor: float + + +@dataclass(frozen=True) +class ProvenanceRecord: + """A validated person-attribution ready to be filed as a wing_lineage drawer. + + Schema mirrors design doc §D3. Persisted shape includes the + context window (typically ±200 chars around the candidate) so the + wing_lineage drawer captures *when/how* the attribution was made, + not just the bare phrase. + + Fields: + - ``person``: canonicalized person identifier — typically the + relation (``"father"``) for un-named family references, or + the proper name (``"James"``, ``"Marie"``) when surfaced. + Becomes the ``room`` of the wing_lineage drawer. + - ``relation_type``: enum-ish string. Design doc §D3 lists + ``family | teacher | partner | colleague | friend | fictional``. + ``relation_type`` is broader than ``person`` — many relation_types + share the same person identifier. + - ``quote``: the exact quoted attribution, if available. Empty + string when the candidate carried no quote. + - ``context``: the ±200-char window surrounding the candidate + in the source text. Required for downstream search. + - ``confidence``: classifier-returned confidence (0.0-1.0). + Caller decides threshold for write to wing_lineage; design + doc §D1 starts at 0.7. + - ``extracted_by``: provenance of the extraction itself — + e.g. ``"heuristic_v1+stub_classifier_v1"`` for D1, + ``"heuristic_v1+qwen3_classifier_v1"`` once D2 ships. + """ + + person: str + relation_type: str + quote: str + context: str + confidence: float + extracted_by: str + + +# --------------------------------------------------------------------------- +# Public API: extract_candidates +# --------------------------------------------------------------------------- + +def extract_candidates(text: str) -> list[ProvenanceCandidate]: + """Heuristic-extract provenance candidate spans from ``text``. + + Runs two passes: + + Pass 1 — Relation + attribution + quote. Highest signal; produces + candidates with ``quote`` populated and confidence_floor + :data:`HEURISTIC_CONFIDENCE_QUOTE`. + + Pass 2 — Relation marker alone. Lower signal; produces candidates + with ``quote=None`` and confidence_floor + :data:`HEURISTIC_CONFIDENCE_RELATION_ONLY`. Matches within + ``_PASS1_DEDUPE_WINDOW_CHARS`` of a Pass-1 hit are dropped to + avoid double-counting the same attribution. + + A future Pass 3 (standalone aphorism in advice context) is + intentionally deferred — aphorism-shape alone has high false- + positive rate, and the D2 classifier is the right place to catch + that case. + + Returns candidates sorted by ``position`` ascending. Empty list + when no patterns match. + + Args: + text: arbitrary text (typically a transcript chunk or diary + entry body). No length cap; large inputs scan linearly. + + Returns: + List of :class:`ProvenanceCandidate`, possibly empty. + """ + + candidates: list[ProvenanceCandidate] = [] + + # Pass 1: relation + (optional attribution verb) + quote. + for m in _RELATION_ATTRIBUTION_QUOTE_RE.finditer(text): + relation = m.group(1).lower() + quote = m.group(2).strip() if m.group(2) is not None else None + candidates.append( + ProvenanceCandidate( + text=m.group(0), + person_hint=relation, + relation_hint=relation, + quote=quote, + position=m.start(), + confidence_floor=HEURISTIC_CONFIDENCE_QUOTE, + ) + ) + + # Pass 2: bare relation marker. Skip overlaps with Pass-1. + pass1_positions = {c.position for c in candidates} + for m in _RELATION_ONLY_RE.finditer(text): + if any(abs(m.start() - p) < _PASS1_DEDUPE_WINDOW_CHARS for p in pass1_positions): + continue + relation = m.group(1).lower() + candidates.append( + ProvenanceCandidate( + text=m.group(0), + person_hint=relation, + relation_hint=relation, + quote=None, + position=m.start(), + confidence_floor=HEURISTIC_CONFIDENCE_RELATION_ONLY, + ) + ) + + candidates.sort(key=lambda c: c.position) + return candidates + + +# --------------------------------------------------------------------------- +# Public API: validate_candidate +# --------------------------------------------------------------------------- + +# Type alias for the v1 classifier interface. Callable accepts the +# context-text window and returns a dict with at minimum +# ``is_provenance: bool``. See :func:`validate_candidate` for the full +# expected schema. +ClassifierFn = Callable[[str], dict] + + +def _stub_classifier_via_candidate(candidate: ProvenanceCandidate) -> dict: + """D1 default classifier — accepts the candidate at confidence 0.5. + + Returns a dict shaped like the v1 classifier interface, but with + fields lifted directly from the candidate (since the heuristic + already extracted them). D2 replaces this with a real + local-substrate call where the LLM does the field extraction. + + Confidence 0.5 is intentionally middle-of-range: callers who want + only high-confidence records can apply a threshold; tests that + just want to exercise the pipeline get a record back. + """ + + return { + "is_provenance": True, + "person": candidate.person_hint, + "relation_type": candidate.relation_hint, + "quote": candidate.quote, + "confidence": 0.5, + } + + +def validate_candidate( + candidate: ProvenanceCandidate, + ctx: str, + classifier: Optional[ClassifierFn] = None, + *, + extractor_label: Optional[str] = None, +) -> Optional[ProvenanceRecord]: + """Validate a heuristic candidate; return a record or ``None``. + + Args: + candidate: the heuristic flag from :func:`extract_candidates`. + ctx: the surrounding context text (typically ±200 chars + around the candidate's position in the source). Becomes + :attr:`ProvenanceRecord.context` on accept. + classifier: optional v1 classifier callable. Signature + ``Callable[[str], dict]``. Expected return keys: + + - ``is_provenance: bool`` (required) — accept/reject + - ``person: str?`` — overrides candidate.person_hint + - ``relation_type: str?`` — overrides candidate.relation_hint + - ``quote: str?`` — overrides candidate.quote + - ``confidence: float`` (recommended) — default 0.0 + + When ``None`` (default), uses the D1 stub which accepts + every candidate at confidence 0.5 with fields lifted from + the candidate. + + extractor_label: provenance string for + :attr:`ProvenanceRecord.extracted_by`. Defaults vary by + classifier presence: ``"heuristic_v1+stub_classifier_v1"`` + when no classifier, ``"heuristic_v1+custom_classifier"`` + otherwise. + + Returns: + :class:`ProvenanceRecord` when the classifier accepts AND + person + relation_type can be resolved. ``None`` when the + classifier rejects OR person/relation_type is missing. + + Caller decides whether the returned record's confidence meets + the threshold for write to wing_lineage. v1 design doc §D1 + starts the threshold at 0.7. + + The classifier exception path is failure-soft: any exception raised + by the classifier callable is caught, logged at DEBUG, and treated + as rejection (returns ``None``). The mining pipeline should never + crash because a classifier had a bad day. + """ + + if classifier is None: + try: + result = _stub_classifier_via_candidate(candidate) + except Exception: # noqa: BLE001 — stub is failure-soft contract + logger.debug("provenance: stub classifier failed", exc_info=True) + return None + if extractor_label is None: + extractor_label = "heuristic_v1+stub_classifier_v1" + else: + try: + result = classifier(ctx) + except Exception: # noqa: BLE001 — classifier failure must not crash mining + logger.debug("provenance: classifier raised", exc_info=True) + return None + if extractor_label is None: + extractor_label = "heuristic_v1+custom_classifier" + + if not isinstance(result, dict): + logger.debug( + "provenance: classifier returned non-dict %r — rejecting", type(result), + ) + return None + + if not result.get("is_provenance", False): + return None + + person = result.get("person") or candidate.person_hint + relation_type = result.get("relation_type") or candidate.relation_hint + quote = result.get("quote") or candidate.quote or "" + + if not person or not relation_type: + logger.debug( + "provenance: missing person/relation_type — rejecting candidate %r", + candidate.text, + ) + return None + + try: + confidence = float(result.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + + return ProvenanceRecord( + person=str(person), + relation_type=str(relation_type), + quote=str(quote), + context=ctx, + confidence=confidence, + extracted_by=extractor_label, + ) + + +# --------------------------------------------------------------------------- +# Schema documentation — wing_lineage drawer shape +# --------------------------------------------------------------------------- + +WING_LINEAGE_SCHEMA_DOC = """\ +wing_lineage drawer schema (Phase 1 D1, per Provenance-Preservation-Design §D3) +================================================================================ + +Persistence layer: + + wing: wing_lineage + room: # "father", "marie", "roshi", "mother-of-jms", ... + content: | + PROVENANCE: + Person: + Relation: + Quote: "" + Context: + Source: + Transmitted_into: + metadata: + person: + relation_type: + is_quote: bool + confidence: float + extracted_by: + source_session: + source_drawer: + +The ``room`` field carries the person-label so a search like +``mempalace_search wing=wing_lineage room=father`` returns everything +attributed to James's father across sessions. Multiple drawers per +person are expected — one per distinct attribution event. + +Writes go through the existing ``mempalace_add_drawer`` MCP tool — +this module produces :class:`ProvenanceRecord` instances; the D3 +mining-integration envelope handles the persistence call. +""" diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000000..9daf3b28b4 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,384 @@ +"""Tests for mempalace.provenance — Phase 1 D1. + +Covers: + - extract_candidates: each regex pattern + composite cases + the + smoke fixtures from the architect's envelope. + - validate_candidate: stub path (no classifier), custom classifier + path, rejection path, exception-soft path. + - The wing_lineage schema doc string is present (regression cover + against accidental deletion). +""" + +from __future__ import annotations + +import pytest + +from mempalace.provenance import ( + HEURISTIC_CONFIDENCE_QUOTE, + HEURISTIC_CONFIDENCE_RELATION_ONLY, + WING_LINEAGE_SCHEMA_DOC, + ProvenanceCandidate, + ProvenanceRecord, + extract_candidates, + validate_candidate, +) + + +# --------------------------------------------------------------------------- +# extract_candidates — Pass 1 (relation + attribution + quote) +# --------------------------------------------------------------------------- + +def test_extract_pass1_relation_attribution_quote_straight_quotes(): + text = "James's father said 'Measure twice, cut once'" + candidates = extract_candidates(text) + assert len(candidates) == 1 + c = candidates[0] + assert c.person_hint == "father" + assert c.relation_hint == "father" + assert c.quote == "Measure twice, cut once" + assert c.confidence_floor == HEURISTIC_CONFIDENCE_QUOTE + + +def test_extract_pass1_relation_attribution_quote_double_quotes(): + text = 'My mother always said "be patient with yourself"' + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "mother" + assert candidates[0].quote == "be patient with yourself" + + +def test_extract_pass1_curly_quotes(): + text = "His grandfather used to say “kindness travels” every winter." + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "grandfather" + assert candidates[0].quote == "kindness travels" + + +def test_extract_pass1_told_me_variant(): + text = "My teacher told me \"the breath is enough\"" + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "teacher" + assert candidates[0].quote == "the breath is enough" + + +def test_extract_pass1_taught_me_variant(): + text = 'My roshi taught me "just sit"' + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "roshi" + assert candidates[0].quote == "just sit" + + +def test_extract_pass1_used_to_say_variant(): + text = "My dad used to say 'measure what matters'" + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "dad" + + +def test_extract_pass1_named_possessive_marie(): + text = "Marie's brother said \"the work is the practice\"" + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "brother" + assert candidates[0].quote == "the work is the practice" + + +# --------------------------------------------------------------------------- +# extract_candidates — Pass 2 (relation marker alone) +# --------------------------------------------------------------------------- + +def test_extract_pass2_roshi_told_no_quote(): + """Architect smoke fixture: "My roshi told me to sit with what is arising" + + Pass-1 requires a quote and won't match; Pass-2 catches the bare + relation marker. Quote is None, confidence_floor is the lower value. + """ + + text = "My roshi told me to sit with what is arising" + candidates = extract_candidates(text) + assert len(candidates) == 1 + c = candidates[0] + assert c.person_hint == "roshi" + assert c.relation_hint == "roshi" + assert c.quote is None + assert c.confidence_floor == HEURISTIC_CONFIDENCE_RELATION_ONLY + + +def test_extract_pass2_bare_my_father(): + text = "I called my father last night." + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].person_hint == "father" + assert candidates[0].quote is None + + +def test_extract_pass2_word_boundary_avoids_fatherland(): + """`\\b` boundary must prevent matching "my fatherland" as "my father".""" + + text = "I returned to my fatherland after a long absence." + candidates = extract_candidates(text) + assert len(candidates) == 0 + + +# --------------------------------------------------------------------------- +# extract_candidates — Pass-1 deduplication of Pass-2 overlaps +# --------------------------------------------------------------------------- + +def test_pass1_match_suppresses_overlapping_pass2(): + """When Pass-1 matches relation+quote, the Pass-2 relation-alone + match at the same position must not be reported as a duplicate + candidate.""" + + text = "My father said 'be still'" + candidates = extract_candidates(text) + assert len(candidates) == 1 + assert candidates[0].quote == "be still" + assert candidates[0].confidence_floor == HEURISTIC_CONFIDENCE_QUOTE + + +# --------------------------------------------------------------------------- +# extract_candidates — Negative cases (smoke fixtures) +# --------------------------------------------------------------------------- + +def test_extract_marie_without_relation_marker_zero_candidates(): + """Architect smoke fixture: "I was discussing this with Marie last night" + + Marie is a proper name but there's no relation marker ("my X" / + "X's family") and no quote markers. v1 heuristic does not match + this case — Marie-only references are out of scope until D2's + classifier sees them. + """ + + text = "I was discussing this with Marie last night." + candidates = extract_candidates(text) + assert candidates == [] + + +def test_extract_operational_content_zero_candidates(): + """Architect smoke fixture: "The Skepticism strategy framing" + (operational content with no attribution).""" + + text = "The Skepticism strategy framing was added to Core Identity v8.3." + candidates = extract_candidates(text) + assert candidates == [] + + +def test_extract_empty_text_returns_empty_list(): + assert extract_candidates("") == [] + + +# --------------------------------------------------------------------------- +# extract_candidates — Multi-candidate ordering +# --------------------------------------------------------------------------- + +def test_extract_multiple_candidates_sorted_by_position(): + text = ( + "My father always said 'measure twice'. " + "Later, my roshi told me to be patient." + ) + candidates = extract_candidates(text) + assert len(candidates) == 2 + # Sorted by position ascending. + assert candidates[0].position < candidates[1].position + assert candidates[0].person_hint == "father" + assert candidates[1].person_hint == "roshi" + + +# --------------------------------------------------------------------------- +# validate_candidate — stub classifier (D1 default) +# --------------------------------------------------------------------------- + +def test_validate_with_stub_accepts_candidate(): + text = "My father said 'measure twice, cut once'" + candidates = extract_candidates(text) + assert len(candidates) == 1 + record = validate_candidate(candidates[0], ctx=text) + assert record is not None + assert record.person == "father" + assert record.relation_type == "father" + assert record.quote == "measure twice, cut once" + assert record.context == text + assert record.confidence == 0.5 + assert record.extracted_by == "heuristic_v1+stub_classifier_v1" + + +def test_validate_stub_handles_candidate_without_quote(): + text = "My roshi told me to sit with what is arising" + candidates = extract_candidates(text) + record = validate_candidate(candidates[0], ctx=text) + assert record is not None + assert record.person == "roshi" + assert record.relation_type == "roshi" + assert record.quote == "" # ProvenanceRecord normalizes None -> empty string + + +# --------------------------------------------------------------------------- +# validate_candidate — custom classifier +# --------------------------------------------------------------------------- + +def test_validate_with_custom_classifier_accepts(): + text = "My father said 'be still'" + candidates = extract_candidates(text) + ctx = text + + def classifier(context_text: str) -> dict: + assert context_text == ctx # context passed through correctly + return { + "is_provenance": True, + "person": "James's father", + "relation_type": "family", + "quote": "be still", + "confidence": 0.92, + } + + record = validate_candidate(candidates[0], ctx=ctx, classifier=classifier) + assert record is not None + assert record.person == "James's father" + assert record.relation_type == "family" # classifier overrides heuristic + assert record.quote == "be still" + assert record.confidence == 0.92 + assert record.extracted_by == "heuristic_v1+custom_classifier" + + +def test_validate_with_custom_classifier_rejects(): + text = "My father said 'be still'" + candidates = extract_candidates(text) + + def classifier(context_text: str) -> dict: + return {"is_provenance": False, "confidence": 0.1} + + record = validate_candidate(candidates[0], ctx=text, classifier=classifier) + assert record is None + + +def test_validate_custom_classifier_label_override(): + text = "My father said 'be still'" + candidates = extract_candidates(text) + + def classifier(context_text: str) -> dict: + return { + "is_provenance": True, + "person": "father", + "relation_type": "family", + "quote": "be still", + "confidence": 0.8, + } + + record = validate_candidate( + candidates[0], + ctx=text, + classifier=classifier, + extractor_label="heuristic_v1+qwen3_classifier_v1", + ) + assert record is not None + assert record.extracted_by == "heuristic_v1+qwen3_classifier_v1" + + +def test_validate_classifier_exception_yields_none(): + """Classifier failure must not crash the mining pipeline.""" + + text = "My father said 'be still'" + candidates = extract_candidates(text) + + def broken_classifier(context_text: str) -> dict: + raise RuntimeError("simulated classifier failure") + + record = validate_candidate( + candidates[0], ctx=text, classifier=broken_classifier, + ) + assert record is None + + +def test_validate_classifier_returns_non_dict_yields_none(): + """Defense against contract drift from custom classifiers.""" + + text = "My father said 'be still'" + candidates = extract_candidates(text) + + record = validate_candidate( + candidates[0], ctx=text, classifier=lambda _ctx: "not a dict", # type: ignore[arg-type] + ) + assert record is None + + +def test_validate_rejects_when_person_missing(): + """If classifier accepts but person can't be resolved from result + or candidate, reject — wing_lineage drawer needs the room key.""" + + candidate = ProvenanceCandidate( + text="some span", + person_hint=None, + relation_hint=None, + quote=None, + position=0, + confidence_floor=0.4, + ) + + def classifier(context_text: str) -> dict: + return {"is_provenance": True, "confidence": 0.8} + + record = validate_candidate(candidate, ctx="ctx", classifier=classifier) + assert record is None + + +def test_validate_non_float_confidence_defaults_to_zero(): + text = "My father said 'be still'" + candidates = extract_candidates(text) + + def classifier(context_text: str) -> dict: + return { + "is_provenance": True, + "person": "father", + "relation_type": "family", + "quote": "be still", + "confidence": "not-a-number", + } + + record = validate_candidate(candidates[0], ctx=text, classifier=classifier) + assert record is not None + assert record.confidence == 0.0 + + +# --------------------------------------------------------------------------- +# Schema doc presence (regression cover) +# --------------------------------------------------------------------------- + +def test_wing_lineage_schema_doc_present(): + """Regression cover: the schema doc string must not be accidentally + deleted. Downstream D3 implementation references it for drawer + construction; design-doc drift between code and doc is hard to + catch otherwise.""" + + assert "wing_lineage" in WING_LINEAGE_SCHEMA_DOC + assert "room: " in WING_LINEAGE_SCHEMA_DOC + assert "PROVENANCE:" in WING_LINEAGE_SCHEMA_DOC + + +# --------------------------------------------------------------------------- +# End-to-end integration — real-shape diary fixture +# --------------------------------------------------------------------------- + +def test_integration_realistic_diary_chunk_yields_validated_record(): + """End-to-end on a sample text shape that real diary drawers produce. + + Goes through both extract_candidates and validate_candidate to + confirm the pipeline composes cleanly without external state. + """ + + sample = ( + "SESSION:2026-05-10|v1.0.0-cutover+skepticism|" + "I shipped Vestige Control v1.0.0 today. " + "James shared his father's saying — 'Measure twice, cut once' — " + "and we used that as the framing for the Skepticism strategy." + ) + candidates = extract_candidates(sample) + # Should pick up "father's saying" via Pass-1 (named possessive variant). + assert len(candidates) >= 1 + record = validate_candidate(candidates[0], ctx=sample) + assert record is not None + assert record.person == "father" + assert record.context == sample + assert record.extracted_by == "heuristic_v1+stub_classifier_v1"