core/formation/smelter.py
Shay 64c5bc4619 feat(epistemic): truth-seeking schema audit — 3 leaks closed, 4 new lanes, 3 new invariants
Audit of the one-mutation-path invariant (ADR-0021 §3) found three leaks
where pack authority or session-state writes could substitute for coherence
judgment. All three landed fixes or partial closures in this push.

Leaks closed:
- Leak A: pack vocab defaulted to COHERENT — flipped to SPECULATIVE in
  language_packs/{compiler,schema}.py; docstring corrected to align with
  ADR-0021 (it was rationalizing the leak).
- Leak B: vault.recall was epistemic-blind — VaultStore.store() now stamps
  every entry with EpistemicStatus (default SPECULATIVE); recall(min_status=)
  filters to admissible-as-evidence tier. All 4 vault-write sites updated.
- Leak C (write-side): generate/proposition.py:198 stored articulated
  propositions unmarked — now stamps SPECULATIVE, breaking the
  fabrication-feedback loop in principle. Read-side audit of 5 call sites
  is the residual.

New architectural invariants (tests/test_architectural_invariants.py):
- INV-21: one-mutation-path allowlist (caught Leak C on first run)
- INV-22: pack lexicon default is SPECULATIVE (Leak A guard)
- INV-23: vault recall epistemic-aware (Leak B guard)

New eval lanes:
- teaching_injection_resistance — ships GREEN at 1.00/1.00/0 (the
  structural anti-injection claim is real and measurable)
- refusal_calibration — honest gap: 0% refusal, 0% fabrication
- contradiction_detection — honest gap: 50% flag via versor-delta heuristic,
  100% false-positive; motivates the proper coherence-checker
- articulation_of_status — honest gap: 0% speculative articulation, 60%
  false certainty; output-side leak surface

New benchmarks:
- benchmarks/footprint.py — total deployed runtime is 7.06 MiB
  (109,358x smaller than Llama 3.1 405B, runs offline, no GPU)
- benchmarks/learning_curve.py — monotonic + replay-deterministic curve
  per lane

Documentation:
- docs/truth_seeking_schema.md — foundational architectural commitment,
  five rules, mapped to human failure modes, leaks published openly
- evals/CLAIMS.md — five-tier public claims doc; Tier 4.5 publishes
  known gaps with named fixes; verification contract at top
- README.md — new pillar between algebraic substrate and language pillar

Includes in-flight formation pipeline scaffolding (formation/, tests/formation/,
docs/formation_pipeline_plan.md) and minor CLI/contracts/gitignore edits
that were already in the working tree at session start.

Verification: 798 passed, 2 skipped, 1 deselected (pre-existing pack-count
test drift unrelated to schema changes).
2026-05-17 07:27:41 -07:00

343 lines
10 KiB
Python

"""Deterministic pattern-based Smelter (Phase 8a).
Turns raw text in an ``OreBundle`` into candidate dataclasses suitable for
the Forge. Strictly pure: no LLM, no network, no async, no floats.
Extraction strategy:
1. *Concepts* - sentences of the form ``"X is defined as Y"``, ``"X means
Y"``, ``"X is a Y"`` where ``X`` is a 1-3 token canonical term.
2. *Relations* - sentences whose normalized form parses cleanly through
``teaching.relation_parse.parse_triple``. We only emit triples that
survive the round-trip.
3. *Counters* - sentences prefixed with a negation marker
(``"It is a misconception that"``, ``"Contrary to common belief,"``,
``"Not"`` ...). The remainder is parsed as a triple.
4. *Ordering hints* - ``"X requires Y"``, ``"X depends on Y"``,
``"before X, Y"`` -> ``OrderingHint(before=Y, after=X)``.
Stable ordering: concepts sorted by ``canonical_term``; relations and
counters by ``(head, relation, tail)``; ordering hints by ``(before, after)``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Final
from formation.candidate import (
ConceptCandidate,
CounterCandidate,
OrderingHint,
RelationCandidate,
SourceRef,
)
from formation.course import OreBundle
from teaching.relation_parse import _RELATIONS, parse_triple
@dataclass(frozen=True, slots=True)
class SmeltedBundle:
"""Output of the Smelter — candidates pending Forge validation."""
concepts: tuple[ConceptCandidate, ...]
relations: tuple[RelationCandidate, ...]
counters: tuple[CounterCandidate, ...]
ordering_hints: tuple[OrderingHint, ...]
_ADAPTER: Final[str] = "smelter/pattern_v1"
# Sentence splitter — keeps things deterministic without external NLP.
_SENTENCE_SPLIT = re.compile(r"(?<=[\.\?!])\s+")
_WS = re.compile(r"\s+")
# Concept definition patterns. Group 1 = canonical term, group 2 = definition.
_CONCEPT_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
re.compile(r"^\s*([a-z][a-z\- ]{0,40}?)\s+is\s+defined\s+as\s+(.+?)\s*$", re.IGNORECASE),
re.compile(r"^\s*([a-z][a-z\- ]{0,40}?)\s+means\s+(.+?)\s*$", re.IGNORECASE),
re.compile(r"^\s*([a-z][a-z\- ]{0,40}?)\s+is\s+an?\s+(.+?)\s*$", re.IGNORECASE),
)
# Negation markers — case-insensitive prefix match.
_COUNTER_MARKERS: Final[tuple[str, ...]] = (
"it is a misconception that ",
"it is a common misconception that ",
"contrary to common belief, ",
"contrary to popular belief, ",
"contrary to belief, ",
"it is not true that ",
"not ",
)
# Ordering patterns. Each yields (after, before).
_ORDERING_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
re.compile(r"^\s*([a-z][a-z\- ]{0,40}?)\s+requires\s+([a-z][a-z\- ]{0,40}?)\s*$", re.IGNORECASE),
re.compile(r"^\s*([a-z][a-z\- ]{0,40}?)\s+depends\s+on\s+([a-z][a-z\- ]{0,40}?)\s*$", re.IGNORECASE),
)
_ORDERING_BEFORE = re.compile(
r"^\s*before\s+([a-z][a-z\- ]{0,40}?)\s*,\s*([a-z][a-z\- ]{0,40}?)\s*$",
re.IGNORECASE,
)
def smelt(
ore_bundle: OreBundle,
source_texts: dict[str, str],
retrieved_at: str,
) -> SmeltedBundle:
"""Extract candidate triples/concepts from ``source_texts``.
``source_texts`` maps ``source_sha`` to the full text body of the
corresponding ``OreEntry``. Sources absent from the map are skipped
silently (they contribute no candidates). ``retrieved_at`` is stamped
on every emitted ``SourceRef``.
"""
concepts_by_term: dict[str, _ConceptAccum] = {}
relations_by_triple: dict[tuple[str, str, str], _TripleAccum] = {}
counters_by_triple: dict[tuple[str, str, str], _TripleAccum] = {}
orderings_by_pair: dict[tuple[str, str], _OrderingAccum] = {}
# Iterate ore entries in their bundle order, but emit in sorted order
# later. Sources missing from ``source_texts`` are simply ignored.
for entry in ore_bundle.entries:
text = source_texts.get(entry.source_sha)
if not text:
continue
for sentence in _split_sentences(text):
if not sentence.strip():
continue
src = SourceRef(
source_sha=entry.source_sha,
span=sentence.strip(),
adapter=_ADAPTER,
retrieved_at=retrieved_at,
)
_extract_concepts(sentence, src, concepts_by_term)
_extract_counters(sentence, src, counters_by_triple)
_extract_ordering_hints(sentence, src, orderings_by_pair)
_extract_relations(sentence, src, relations_by_triple)
concepts = tuple(
ConceptCandidate(
canonical_term=term,
definition=accum.definition,
sources=accum.sources_tuple(),
)
for term, accum in sorted(concepts_by_term.items())
)
relations = tuple(
RelationCandidate(
head=key[0],
relation=key[1],
tail=key[2],
sources=accum.sources_tuple(),
)
for key, accum in sorted(relations_by_triple.items())
)
counters = tuple(
CounterCandidate(
head=key[0],
relation=key[1],
tail=key[2],
sources=accum.sources_tuple(),
)
for key, accum in sorted(counters_by_triple.items())
)
ordering_hints = tuple(
OrderingHint(before=key[0], after=key[1], sources=accum.sources_tuple())
for key, accum in sorted(orderings_by_pair.items())
)
return SmeltedBundle(
concepts=concepts,
relations=relations,
counters=counters,
ordering_hints=ordering_hints,
)
# ---------- accumulators ----------
class _SourceSet:
"""Order-preserving, dedup-by-source-sha collector of SourceRefs."""
__slots__ = ("_seen", "_refs")
def __init__(self) -> None:
self._seen: set[str] = set()
self._refs: list[SourceRef] = []
def add(self, src: SourceRef) -> None:
if src.source_sha in self._seen:
return
self._seen.add(src.source_sha)
self._refs.append(src)
def sources_tuple(self) -> tuple[SourceRef, ...]:
# Sort by source_sha for stable ordering across runs.
return tuple(sorted(self._refs, key=lambda s: s.source_sha))
class _ConceptAccum(_SourceSet):
__slots__ = ("definition",)
def __init__(self, definition: str) -> None:
super().__init__()
self.definition = definition
class _TripleAccum(_SourceSet):
__slots__ = ()
class _OrderingAccum(_SourceSet):
__slots__ = ()
# ---------- helpers ----------
def _split_sentences(text: str) -> list[str]:
# Normalize newlines to spaces before splitting so multi-line ore text
# is handled identically to single-line text.
flat = _WS.sub(" ", text.strip())
if not flat:
return []
return _SENTENCE_SPLIT.split(flat)
def _clean_sentence(sentence: str) -> str:
s = sentence.strip()
while s and s[-1] in ".?!":
s = s[:-1].rstrip()
return s
def _valid_term(token: str) -> bool:
"""Canonical term: 1-3 alphabetic tokens, all lowercase after .lower()."""
if not token:
return False
parts = token.split()
if not 1 <= len(parts) <= 3:
return False
return all(re.fullmatch(r"[a-z][a-z\-]*", p) for p in parts)
def _extract_concepts(
sentence: str,
src: SourceRef,
out: dict[str, _ConceptAccum],
) -> None:
cleaned = _clean_sentence(sentence)
if not cleaned:
return
for pattern in _CONCEPT_PATTERNS:
match = pattern.match(cleaned)
if match is None:
continue
term = match.group(1).strip().lower()
definition = match.group(2).strip().lower()
if not _valid_term(term):
continue
if not definition:
continue
accum = out.get(term)
if accum is None:
out[term] = accum = _ConceptAccum(definition=definition)
accum.add(src)
return # First matching pattern wins.
def _extract_relations(
sentence: str,
src: SourceRef,
out: dict[tuple[str, str, str], _TripleAccum],
) -> None:
cleaned = _clean_sentence(sentence).lower()
if not cleaned:
return
triple = parse_triple(cleaned)
if triple is None:
return
head, relation, tail = triple
if relation not in _RELATIONS:
return
key = (head, relation, tail)
accum = out.get(key)
if accum is None:
out[key] = accum = _TripleAccum()
accum.add(src)
def _extract_counters(
sentence: str,
src: SourceRef,
out: dict[tuple[str, str, str], _TripleAccum],
) -> None:
cleaned = _clean_sentence(sentence)
if not cleaned:
return
lower = cleaned.lower()
remainder: str | None = None
for marker in _COUNTER_MARKERS:
if lower.startswith(marker):
remainder = cleaned[len(marker):].strip()
break
if remainder is None:
return
triple = parse_triple(remainder)
if triple is None:
return
head, relation, tail = triple
if relation not in _RELATIONS:
return
key = (head, relation, tail)
accum = out.get(key)
if accum is None:
out[key] = accum = _TripleAccum()
accum.add(src)
def _extract_ordering_hints(
sentence: str,
src: SourceRef,
out: dict[tuple[str, str], _OrderingAccum],
) -> None:
cleaned = _clean_sentence(sentence)
if not cleaned:
return
before_match = _ORDERING_BEFORE.match(cleaned)
if before_match is not None:
after = before_match.group(1).strip().lower()
before = before_match.group(2).strip().lower()
_record_ordering(before, after, src, out)
return
for pattern in _ORDERING_PATTERNS:
match = pattern.match(cleaned)
if match is None:
continue
after = match.group(1).strip().lower()
before = match.group(2).strip().lower()
_record_ordering(before, after, src, out)
return
def _record_ordering(
before: str,
after: str,
src: SourceRef,
out: dict[tuple[str, str], _OrderingAccum],
) -> None:
if not _valid_term(before) or not _valid_term(after):
return
if before == after:
return
key = (before, after)
accum = out.get(key)
if accum is None:
out[key] = accum = _OrderingAccum()
accum.add(src)