core/formation/forge.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

459 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The Forge — the single trust boundary between untrusted text and the manifold.
The Forge accepts ``RelationCandidate`` / ``ConceptCandidate`` /
``CounterCandidate`` objects (typically emitted by the Smelter) and runs each
through every validation rule in order. Candidates that pass every rule are
emitted as part of a ``ValidatedTripleSet`` and acquire
``EpistemicStatus.SPECULATIVE`` when handed to the teaching layer.
Validation rules, in order:
R1. Triple is well-typed. Must parse via
``teaching.relation_parse.parse_triple`` so the head/relation/tail
align with the cognition pack's relation predicates.
R2. Identity-axis collision screen. No triple may name an identity-axis
term in its head or tail. Identity is not editable via mining.
R3. Source allow-list. Every cited source SHA must appear in the
``SourceAllowlist``. Quarantined otherwise.
R4. Pack collision check. The triple must not already exist in the
language pack or ``TeachingStore`` — duplicate triples are dropped
(not quarantined; they are simply redundant).
R5. Cross-reference rule. A candidate graduates iff:
- it has at least one ``"primary"`` source, OR
- it has ≥2 independent ``"secondary"`` source SHAs.
LLM-sourced candidates carry tier ``"llm"`` and never satisfy R5 on
their own; they require ≥2 non-LLM corroborators (i.e. R5 evaluates
only the non-LLM citations).
Cache: a ``ValidatedTripleCache`` keyed by ``(head, relation, tail)`` stores
previously-validated triples and short-circuits re-validation. The cache is
append-only and content-addressed.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Iterable, Protocol
from formation.allowlist import SourceAllowlist
from formation.candidate import (
CandidateState,
ConceptCandidate,
CounterCandidate,
OrderingHint,
RelationCandidate,
SourceRef,
)
from formation.course import ValidatedTripleSet
from formation.hashing import canonical_json, sha256_of
from teaching.relation_parse import parse_triple
# Default identity-axis terms. These match the typical CORE identity-manifold
# vocabulary (truth, identity, self, etc.) and can be extended per-deployment.
# Curated, not learned — per CLAUDE.md "compact, curated packs" doctrine.
DEFAULT_IDENTITY_AXIS_TERMS: Final[frozenset[str]] = frozenset({
"identity",
"self",
"truth",
"truthfulness",
"coherence",
"honesty",
"core",
"manifold",
"operator",
"claude",
"anthropic",
})
class _TripleHaystack(Protocol):
"""Minimal protocol for pack/teaching-store collision lookup."""
def triples(self) -> tuple[tuple[str, str, str], ...]: ...
@dataclass(frozen=True, slots=True)
class RejectedCandidate:
"""A candidate that failed a Forge rule.
``reason`` is one of:
"malformed", "identity_axis_collision",
"invalid_source", "duplicate", "insufficient_corroboration".
"""
head: str
relation: str
tail: str
reason: str
detail: str = ""
@dataclass(frozen=True, slots=True)
class ForgeResult:
validated: tuple[RelationCandidate, ...]
quarantined: tuple[RelationCandidate, ...]
duplicates: tuple[RelationCandidate, ...]
rejections: tuple[RejectedCandidate, ...]
class ValidatedTripleCache:
"""Append-only file-backed cache of validated triples.
Keyed by ``(head, relation, tail)``. A cache hit means the triple has
been validated previously for *some* subject and may be reused without
re-running Forge rules R3R5 (R1 and R2 are properties of the triple
itself and are re-checked cheaply).
"""
def __init__(self, path: Path | str | None = None) -> None:
self._path = Path(path).resolve() if path is not None else None
self._entries: dict[tuple[str, str, str], dict[str, object]] = {}
if self._path is not None and self._path.exists():
self._load()
def _load(self) -> None:
import json
assert self._path is not None
for line in self._path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
entry = json.loads(line)
key = (entry["head"], entry["relation"], entry["tail"])
self._entries[key] = entry
def contains(self, head: str, relation: str, tail: str) -> bool:
return (head, relation, tail) in self._entries
def remember(
self, candidate: RelationCandidate, validated_set_sha: str
) -> None:
key = (candidate.head, candidate.relation, candidate.tail)
if key in self._entries:
return
entry = {
"head": candidate.head,
"relation": candidate.relation,
"tail": candidate.tail,
"validated_set_sha": validated_set_sha,
"source_shas": sorted({s.source_sha for s in candidate.sources}),
}
self._entries[key] = entry
if self._path is not None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("a", encoding="utf-8") as fh:
# canonical JSON per line; JSON-lines is fine for append-only.
fh.write(canonical_json(entry).decode("utf-8") + "\n")
def __len__(self) -> int:
return len(self._entries)
class Forge:
"""The trust boundary. Stateless validator over candidate inputs.
Construct once with the allow-list, identity-axis terms, and any pack /
teaching-store haystacks; invoke ``validate(...)`` per Smelter output.
"""
def __init__(
self,
allowlist: SourceAllowlist,
identity_axis_terms: Iterable[str] = DEFAULT_IDENTITY_AXIS_TERMS,
pack_haystack: _TripleHaystack | None = None,
teaching_store: _TripleHaystack | None = None,
cache: ValidatedTripleCache | None = None,
) -> None:
self._allowlist = allowlist
self._identity_terms = frozenset(t.strip().lower() for t in identity_axis_terms)
self._pack = pack_haystack
self._teach = teaching_store
self._cache = cache if cache is not None else ValidatedTripleCache()
@property
def cache(self) -> ValidatedTripleCache:
return self._cache
# ------------------------- public surface -------------------------
def validate(
self,
subject_id: str,
concepts: Iterable[ConceptCandidate] = (),
relations: Iterable[RelationCandidate] = (),
counters: Iterable[CounterCandidate] = (),
ordering_hints: Iterable[OrderingHint] = (),
) -> ValidatedTripleSet:
"""Run every rule over the candidate inputs and emit a ``ValidatedTripleSet``."""
validated_relations: list[RelationCandidate] = []
quarantined_relations: list[RelationCandidate] = []
existing = self._existing_triples()
for cand in relations:
outcome = self._evaluate_relation(cand, existing)
if outcome is None:
# duplicate; silently drop.
continue
if outcome.state is CandidateState.VALIDATED:
validated_relations.append(outcome)
else:
quarantined_relations.append(outcome)
validated_concepts = self._validate_concepts(concepts)
validated_counters = self._validate_counters(counters)
vts = ValidatedTripleSet(
subject_id=subject_id,
concepts=tuple(validated_concepts),
relations=tuple(validated_relations),
counters=tuple(validated_counters),
ordering_hints=tuple(ordering_hints),
quarantined=tuple(quarantined_relations),
)
vts_sha = sha256_of(_vts_for_hashing(vts))
for v in validated_relations:
self._cache.remember(v, vts_sha)
return vts
# ------------------------- relation rules -------------------------
def _evaluate_relation(
self,
cand: RelationCandidate,
existing: frozenset[tuple[str, str, str]],
) -> RelationCandidate | None:
"""Run rules R1R5 against a single relation candidate.
Returns the candidate with updated ``state`` and ``rejection_reason``,
or ``None`` if the candidate is a duplicate of an existing pack/store
triple (silently dropped).
"""
triple = (cand.head, cand.relation, cand.tail)
# R1: well-typed.
if not self._is_well_typed(cand):
return _quarantined(cand, "malformed")
# R2: identity-axis collision.
if self._collides_with_identity_axis(cand):
return _quarantined(cand, "identity_axis_collision")
# R3: source allow-list.
bad_source = self._first_bad_source(cand)
if bad_source is not None:
return _quarantined(
cand, "invalid_source", detail=bad_source
)
# R4: pack/teaching-store duplicate.
if triple in existing:
return None # silent drop
# R4b: cache hit short-circuits R5 — already validated previously.
if self._cache.contains(*triple):
return _validated(cand)
# R5: cross-reference rule.
if not self._satisfies_cross_reference(cand):
return _quarantined(cand, "insufficient_corroboration")
return _validated(cand)
def _is_well_typed(self, cand: RelationCandidate) -> bool:
if not cand.head or not cand.relation or not cand.tail:
return False
# Reconstruct a "head relation tail" sentence and round-trip it
# through the project's relation parser so a well-typed candidate is
# one the rest of the system can actually parse.
sentence = f"{cand.head} {cand.relation.replace('_', ' ')} {cand.tail}"
parsed = parse_triple(sentence)
if parsed is None:
return False
head, relation, tail = parsed
return (
head == cand.head.lower()
and relation == cand.relation
and tail == cand.tail.lower()
)
def _collides_with_identity_axis(self, cand: RelationCandidate) -> bool:
for side in (cand.head, cand.tail):
for term in _tokens(side):
if term in self._identity_terms:
return True
return False
def _first_bad_source(self, cand: RelationCandidate) -> str | None:
if not cand.sources:
return "no_sources"
for src in cand.sources:
if not _is_clean_sha(src.source_sha):
return f"path_traversal:{src.source_sha!r}"
if not self._allowlist.contains(src.source_sha):
return f"not_in_allowlist:{src.source_sha}"
return None
def _satisfies_cross_reference(self, cand: RelationCandidate) -> bool:
tiers = [self._allowlist.tier_of(s.source_sha) for s in cand.sources]
# LLM citations never count toward R5 on their own.
non_llm_distinct = {
(s.source_sha, t)
for s, t in zip(cand.sources, tiers)
if t in ("primary", "secondary")
}
if any(t == "primary" for _sha, t in non_llm_distinct):
return True
secondary_shas = {sha for sha, t in non_llm_distinct if t == "secondary"}
return len(secondary_shas) >= 2
# ------------------------- helpers -------------------------
def _existing_triples(self) -> frozenset[tuple[str, str, str]]:
triples: set[tuple[str, str, str]] = set()
if self._pack is not None:
triples.update(self._pack.triples())
if self._teach is not None:
triples.update(self._teach.triples())
return frozenset(triples)
def _validate_concepts(
self, concepts: Iterable[ConceptCandidate]
) -> list[ConceptCandidate]:
# Concepts are admissible whenever they have ≥1 allow-listed source
# whose SHA is clean. Identity-axis terms are forbidden in the
# canonical term.
out: list[ConceptCandidate] = []
for cc in concepts:
if not cc.sources:
continue
if any(t in self._identity_terms for t in _tokens(cc.canonical_term)):
continue
if not all(_is_clean_sha(s.source_sha) for s in cc.sources):
continue
if not all(self._allowlist.contains(s.source_sha) for s in cc.sources):
continue
out.append(
ConceptCandidate(
canonical_term=cc.canonical_term,
definition=cc.definition,
sources=cc.sources,
state=CandidateState.VALIDATED,
)
)
return out
def _validate_counters(
self, counters: Iterable[CounterCandidate]
) -> list[CounterCandidate]:
# Counters are explicitly false-but-plausible; they are stored for
# Phase 4 boundary hardening. We still require allow-listed sources
# and identity-axis cleanliness.
out: list[CounterCandidate] = []
for cc in counters:
if not cc.sources:
continue
if any(
t in self._identity_terms
for side in (cc.head, cc.tail)
for t in _tokens(side)
):
continue
if not all(_is_clean_sha(s.source_sha) for s in cc.sources):
continue
if not all(self._allowlist.contains(s.source_sha) for s in cc.sources):
continue
out.append(
CounterCandidate(
head=cc.head,
relation=cc.relation,
tail=cc.tail,
sources=cc.sources,
state=CandidateState.VALIDATED,
)
)
return out
# ---------- module-private helpers ----------
def _quarantined(
cand: RelationCandidate, reason: str, detail: str = ""
) -> RelationCandidate:
return RelationCandidate(
head=cand.head,
relation=cand.relation,
tail=cand.tail,
sources=cand.sources,
state=CandidateState.QUARANTINED,
rejection_reason=reason if not detail else f"{reason}:{detail}",
)
def _validated(cand: RelationCandidate) -> RelationCandidate:
return RelationCandidate(
head=cand.head,
relation=cand.relation,
tail=cand.tail,
sources=cand.sources,
state=CandidateState.VALIDATED,
rejection_reason="",
)
def _tokens(text: str) -> tuple[str, ...]:
return tuple(t.strip().lower() for t in text.replace("_", " ").split() if t.strip())
def _is_clean_sha(sha: str) -> bool:
import re
return bool(re.fullmatch(r"[0-9a-f]{64}", sha))
def _vts_for_hashing(vts: ValidatedTripleSet) -> dict[str, object]:
"""Project a ``ValidatedTripleSet`` to a canonical-JSON-safe dict."""
return {
"subject_id": vts.subject_id,
"schema_version": vts.schema_version,
"concepts": sorted(
[
{
"canonical_term": c.canonical_term,
"definition": c.definition,
"source_shas": sorted({s.source_sha for s in c.sources}),
}
for c in vts.concepts
],
key=lambda d: d["canonical_term"],
),
"relations": sorted(
[
{
"head": r.head,
"relation": r.relation,
"tail": r.tail,
"source_shas": sorted({s.source_sha for s in r.sources}),
}
for r in vts.relations
],
key=lambda d: (d["head"], d["relation"], d["tail"]),
),
"counters": sorted(
[
{"head": c.head, "relation": c.relation, "tail": c.tail}
for c in vts.counters
],
key=lambda d: (d["head"], d["relation"], d["tail"]),
),
"ordering_hints": sorted(
[{"before": h.before, "after": h.after} for h in vts.ordering_hints],
key=lambda d: (d["before"], d["after"]),
),
}