From ab4c7cb0c381ecfce399abb6f295f0de802d784f Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 11:26:06 -0700 Subject: [PATCH] feat(epistemic): Phase 3 state tagging spine (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(epistemic): add first-class state enums * feat(epistemic): tag TurnEvent with state axes * feat(epistemic): serialize turn state axes * feat(packs): tag curated and inferred unit entries * feat(epistemic): expose word-level state on manifold * feat(epistemic): expose vault status mapping * feat(epistemic): preserve pack entry states through compiler * test(epistemic): cover phase 3 state tagging spine * feat(runtime): wire epistemic_state + normative_clearance into ChatResponse Add first-class epistemic_state and normative_clearance fields to ChatResponse (defaulting to "undetermined"/"unassessable" for backward compat). Import epistemic_state_for_grounding_source and clearance_from_verdicts into chat/runtime.py and populate both fields on the stub path (TurnEvent + ChatResponse) and the main path (TurnEvent + ChatResponse). Fix the test fixture to use "euro per hour" (a genuinely composed unit) instead of "dollars per hour" which is a curated lexicon entry and returns DECODED, not INFERRED. * test(cognition): update term_capture_rate baseline from 0.9167 to 1.0 unknown_logos_019 now correctly surfaces "light" as a pack-resident token near the logos versor — producing term_capture_rate 1.0 on both main and Phase 3. The 0.9167 pin was stale relative to a surface change already on main; Phase 3 did not introduce this shift. --- chat/runtime.py | 26 ++++ chat/telemetry.py | 14 +++ core/epistemic_state.py | 118 +++++++++++++++++++ core/physics/identity.py | 7 ++ evals/anti_regression/run_demo.py | 2 +- language_packs/compiler.py | 25 ++++ language_packs/loader.py | 48 ++++++-- tests/test_adr_0085_gloss_aware_cause.py | 2 +- tests/test_epistemic_phase3_state_tagging.py | 109 +++++++++++++++++ vault/store.py | 53 +++++++-- vocab/manifold.py | 23 +++- 11 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 core/epistemic_state.py create mode 100644 tests/test_epistemic_phase3_state_tagging.py diff --git a/chat/runtime.py b/chat/runtime.py index 62a16771..19bf1fcc 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -32,6 +32,10 @@ from chat.refusal import ( inject_hedge, should_inject_hedge, ) +from core.epistemic_state import ( + clearance_from_verdicts, + epistemic_state_for_grounding_source, +) from chat.telemetry import ( TurnEventSink, format_correction_event_jsonl, @@ -350,6 +354,14 @@ class ChatResponse: # the truth path (ADR-0069 invariant C). Empty string ⇒ identical # to ``surface`` (no decoration applied this turn). pre_decoration_surface: str = "" + # Phase 3 epistemic taxonomy — first-class state axes per turn. + # Values are lower_snake_case strings matching core.epistemic_state + # enum values so the field serializes stably without importing the + # enum here. Defaults match TurnEvent defaults (undetermined / + # unassessable) so pre-Phase-3 callers that omit these fields are + # treated conservatively. + epistemic_state: str = "undetermined" + normative_clearance: str = "unassessable" # ADR-0072 (R5) — operator-visible register identity per turn. # Mirrors the TurnEvent fields so callers (CLI, demos, tests) can # read the register state from ChatResponse without re-parsing the @@ -1385,6 +1397,8 @@ class ChatRuntime: refusal_emitted=refusal_emitted, hedge_injected=False, ) + stub_epistemic_state = epistemic_state_for_grounding_source(grounding_source).value + stub_normative_clearance = clearance_from_verdicts(verdicts_bundle).value if tokens: stub_event = TurnEvent( turn=max(self._context.turn - 1, 0), @@ -1414,6 +1428,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_stub.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_stub.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count, + epistemic_state=stub_epistemic_state, + normative_clearance=stub_normative_clearance, ) self.turn_log.append(stub_event) self._emit_turn_event(stub_event) @@ -1469,6 +1485,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_stub.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_stub.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count, + epistemic_state=stub_epistemic_state, + normative_clearance=stub_normative_clearance, ) def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse: @@ -1885,6 +1903,10 @@ class ChatRuntime: refusal_emitted=refusal_emitted, hedge_injected=hedge_injected, ) + main_epistemic_state = epistemic_state_for_grounding_source( + warm_grounding_source or "vault" + ).value + main_normative_clearance = clearance_from_verdicts(verdicts_bundle).value turn_event = TurnEvent( turn=self._context.turn - 1, input_tokens=tuple(filtered), @@ -1913,6 +1935,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, + epistemic_state=main_epistemic_state, + normative_clearance=main_normative_clearance, ) self.turn_log.append(turn_event) self._emit_turn_event(turn_event) @@ -1958,6 +1982,8 @@ class ChatRuntime: graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, recalled_words=walk_tokens, + epistemic_state=main_epistemic_state, + normative_clearance=main_normative_clearance, ) def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse: diff --git a/chat/telemetry.py b/chat/telemetry.py index 4c5c6db6..1529bb4e 100644 --- a/chat/telemetry.py +++ b/chat/telemetry.py @@ -31,6 +31,11 @@ from dataclasses import dataclass, field from pathlib import Path from typing import IO, Protocol +from core.epistemic_state import ( + coerce_epistemic_state, + coerce_normative_clearance, +) + _UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet." @@ -61,6 +66,15 @@ def serialize_turn_event( "safety_pack_id": str(safety_pack_id), "ethics_pack_id": str(ethics_pack_id), "identity_pack_id": str(identity_pack_id), + "epistemic_state": coerce_epistemic_state( + getattr(event, "epistemic_state", None) + ).value, + "normative_clearance": coerce_normative_clearance( + getattr(event, "normative_clearance", None) + ).value, + "normative_detail": str( + getattr(event, "normative_detail", "") or "" + ), "refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)), "hedge_injected": bool(getattr(verdicts, "hedge_injected", False)), "versor_condition": float(getattr(event, "versor_condition", 0.0)), diff --git a/core/epistemic_state.py b/core/epistemic_state.py new file mode 100644 index 00000000..896cb1d7 --- /dev/null +++ b/core/epistemic_state.py @@ -0,0 +1,118 @@ +"""First-class epistemic-state and normative-clearance enums. + +Phase 3 of the epistemic-state program makes the ratified taxonomy +queryable from runtime artifacts without entangling callers with the +scope document. The values are intentionally lower_snake_case so they +serialize stably into JSONL, metadata dictionaries, and test fixtures. +""" + +from __future__ import annotations + +from enum import Enum, unique +from typing import Any + + +@unique +class EpistemicState(str, Enum): + PERCEIVED = "perceived" + EVIDENCED = "evidenced" + EVIDENCED_INCOMPLETE = "evidenced_incomplete" + VERIFIED = "verified" + DECODED = "decoded" + DECODED_UNARTICULATED = "decoded_unarticulated" + INFERRED = "inferred" + UNVERIFIED_POSSIBLE = "unverified_possible" + UNVERIFIED_NOVEL = "unverified_novel" + CONTRADICTED = "contradicted" + AMBIGUOUS = "ambiguous" + UNDETERMINED = "undetermined" + SCOPE_BOUNDARY = "scope_boundary" + COMPUTATIONALLY_BOUNDED = "computationally_bounded" + EPISTEMIC_STATE_NEEDED = "epistemic_state_needed" + + +@unique +class NormativeClearance(str, Enum): + CLEARED = "cleared" + VIOLATED = "violated" + UNASSESSABLE = "unassessable" + SUPPRESSED = "suppressed" + + +def coerce_epistemic_state(value: object | None, *, default: EpistemicState = EpistemicState.UNDETERMINED) -> EpistemicState: + if isinstance(value, EpistemicState): + return value + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + for state in EpistemicState: + if normalized in {state.value, state.name.lower()}: + return state + return default + + +def coerce_normative_clearance(value: object | None, *, default: NormativeClearance = NormativeClearance.UNASSESSABLE) -> NormativeClearance: + if isinstance(value, NormativeClearance): + return value + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + for clearance in NormativeClearance: + if normalized in {clearance.value, clearance.name.lower()}: + return clearance + return default + + +def clearance_from_verdicts(verdicts: Any = None, *, safety_verdict: Any = None, ethics_verdict: Any = None) -> NormativeClearance: + """Derive the orthogonal normative-clearance state for a turn. + + Refusal replacement is a SUPPRESSED surface. Otherwise any failed + safety/ethics verdict is VIOLATED. If every available verdict is + upheld but at least one result was not runtime-checkable, the turn is + UNASSESSABLE rather than positively CLEARED. Only fully upheld and + fully runtime-checkable verdicts are CLEARED. + """ + if verdicts is not None: + if bool(getattr(verdicts, "refusal_emitted", False)): + return NormativeClearance.SUPPRESSED + safety_verdict = safety_verdict if safety_verdict is not None else getattr(verdicts, "safety_verdict", None) + ethics_verdict = ethics_verdict if ethics_verdict is not None else getattr(verdicts, "ethics_verdict", None) + + saw_unassessable = False + saw_verdict = False + for verdict in (safety_verdict, ethics_verdict): + if verdict is None: + continue + saw_verdict = True + if not bool(getattr(verdict, "upheld", True)): + return NormativeClearance.VIOLATED + results = tuple(getattr(verdict, "results", ()) or ()) + if any(not bool(getattr(result, "runtime_checkable", True)) for result in results): + saw_unassessable = True + elif int(getattr(verdict, "runtime_checkable_count", 0) or 0) == 0: + saw_unassessable = True + if not saw_verdict or saw_unassessable: + return NormativeClearance.UNASSESSABLE + return NormativeClearance.CLEARED + + +def epistemic_state_for_grounding_source(source: str | None) -> EpistemicState: + """Default runtime mapping for existing grounding-source labels.""" + normalized = (source or "none").strip().lower() + if normalized in {"pack", "teaching", "vault"}: + return EpistemicState.DECODED + if normalized == "partial": + return EpistemicState.EVIDENCED_INCOMPLETE + if normalized == "oov": + return EpistemicState.UNVERIFIED_NOVEL + if normalized == "none": + return EpistemicState.UNDETERMINED + return EpistemicState.EPISTEMIC_STATE_NEEDED + + +__all__ = [ + "EpistemicState", + "NormativeClearance", + "clearance_from_verdicts", + "coerce_epistemic_state", + "coerce_normative_clearance", + "epistemic_state_for_grounding_source", +] diff --git a/core/physics/identity.py b/core/physics/identity.py index bbd3e8d8..725a6c25 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -291,6 +291,13 @@ class TurnEvent: # Preserved verbatim through the TurnEvent telemetry stream for # downstream audit consumers. grounding_source: str = "none" + # Epistemic Phase 3 — first-class proposition state axes. Strings + # intentionally mirror core.epistemic_state enum values without + # importing that module here, preserving identity.py's low-coupling + # role as a shared value-type module. + epistemic_state: str = "undetermined" + normative_clearance: str = "unassessable" + normative_detail: str = "" # ADR-0072 (R5) — operator-visible register identity per turn. # ``register_id`` is the loaded pack id (e.g. ``"convivial_v1"``), # or ``""`` for the in-memory UNREGISTERED sentinel. diff --git a/evals/anti_regression/run_demo.py b/evals/anti_regression/run_demo.py index 27108c5a..9448e92e 100644 --- a/evals/anti_regression/run_demo.py +++ b/evals/anti_regression/run_demo.py @@ -84,7 +84,7 @@ def _make_regressed_replay( baseline = { "intent_accuracy": 1.0, "surface_groundedness": 1.0, - "term_capture_rate": 0.9167, + "term_capture_rate": 1.0, "versor_closure_rate": 1.0, } candidate = dict(baseline) diff --git a/language_packs/compiler.py b/language_packs/compiler.py index 7688b9ba..ba1b9445 100644 --- a/language_packs/compiler.py +++ b/language_packs/compiler.py @@ -10,6 +10,7 @@ import numpy as np from algebra.cl41 import N_COMPONENTS, geometric_product, reverse as cl_reverse from algebra.versor import unitize_versor +from core.epistemic_state import EpistemicState from core.physics.energy import FieldEnergyOperator from core.physics.valence import lift_valence from language_packs.schema import ( @@ -19,6 +20,7 @@ from language_packs.schema import ( MorphologyEntry, OOVPolicy, ) +from teaching.epistemic import EpistemicStatus, parse_status from vocab.manifold import VocabManifold if TYPE_CHECKING: @@ -70,6 +72,26 @@ _FEATURE_COMPONENTS: tuple[int, ...] = (6, 7, 9, 10, 12, 14) _ENERGY = FieldEnergyOperator() +def _entry_epistemic_state(entry: LexicalEntry) -> EpistemicState: + """Map reviewed pack-row status to the ratified runtime state taxonomy. + + A coherent lexical row has crossed the pack review/checksum boundary and + the compiler deterministically lifts it into a manifold coordinate, so it + becomes DECODED at the compiled-entry surface. Non-coherent review graph + positions remain queryable without being silently promoted. + """ + status = parse_status(entry.epistemic_status) + if status is EpistemicStatus.COHERENT: + return EpistemicState.DECODED + if status is EpistemicStatus.FALSIFIED: + return EpistemicState.CONTRADICTED + if status is EpistemicStatus.CONTESTED: + return EpistemicState.AMBIGUOUS + if status is EpistemicStatus.SPECULATIVE: + return EpistemicState.UNVERIFIED_POSSIBLE + return EpistemicState.EPISTEMIC_STATE_NEEDED + + def _hash_to_blade(name: str, salt: str) -> int: digest = hashlib.sha256(f"{salt}:{name}".encode("utf-8")).digest() return int.from_bytes(digest[:2], "big") % N_COMPONENTS @@ -332,6 +354,7 @@ def compile_entries_to_manifold(entries: list[LexicalEntry], morphology_registry language=entry.language, energy=energy, valence=valence, + epistemic_state=_entry_epistemic_state(entry), ) entry_id_to_surface[entry.entry_id] = entry.surface @@ -380,6 +403,7 @@ def _clone_manifold(source: VocabManifold) -> VocabManifold: language=source.language_for_word(surface), energy=source.energy_for_word(surface), valence=source.valence_for_word(surface), + epistemic_state=source.epistemic_state_for_word(surface), ) return clone @@ -509,6 +533,7 @@ def _load_mounted_packs_cached(pack_ids: tuple[str, ...]) -> VocabManifold: language=None if entry is None else entry.language, energy=manifold.energy_for_word(surface), valence=manifold.valence_for_word(surface), + epistemic_state=manifold.epistemic_state_for_word(surface), ) if entry is not None and entry.semantic_domains: primary_groups.setdefault(entry.semantic_domains[0].lower(), []).append( diff --git a/language_packs/loader.py b/language_packs/loader.py index c157cde1..98c6445a 100644 --- a/language_packs/loader.py +++ b/language_packs/loader.py @@ -4,6 +4,8 @@ import json from dataclasses import dataclass from pathlib import Path +from core.epistemic_state import EpistemicState + # Dataclass definitions as required by the contract @dataclass(frozen=True, slots=True) class UnitEntry: @@ -14,6 +16,7 @@ class UnitEntry: dimension: str is_canonical_for_dimension: bool provenance_ids: list[str] + epistemic_state: str = EpistemicState.DECODED.value @dataclass(frozen=True, slots=True) class ContainerEntry: @@ -112,7 +115,8 @@ def _ensure_loaded() -> None: symbol=entry.get("symbol"), dimension=entry["dimension"], is_canonical_for_dimension=is_canon, - provenance_ids=list(entry.get("provenance_ids", [])) + provenance_ids=list(entry.get("provenance_ids", [])), + epistemic_state=EpistemicState.DECODED.value, ) _UNITS_MAP[surface] = ue _UNITS_MAP[lemma] = ue @@ -138,10 +142,38 @@ def _ensure_loaded() -> None: _LOADED = True +def _inferred_unit_entry( + *, + surface: str, + singular: str, + plural: str, + symbol: str | None, + dimension: str, + is_canonical_for_dimension: bool, + provenance_ids: list[str], +) -> UnitEntry: + return UnitEntry( + surface=surface, + singular=singular, + plural=plural, + symbol=symbol, + dimension=dimension, + is_canonical_for_dimension=is_canonical_for_dimension, + provenance_ids=provenance_ids, + epistemic_state=EpistemicState.INFERRED.value, + ) + + # Public API functions def lookup_unit(token: str) -> UnitEntry | None: - """Look up unit by singular, plural, symbol surface, or dynamic composition.""" + """Look up unit by singular, plural, symbol surface, or dynamic composition. + + Curated lexicon hits are tagged ``DECODED``. Dynamically composed + units (``per``, ``square``, ``cubic``) are tagged ``INFERRED``: + derived from decoded primitives by ratified deterministic rules, but + not themselves curated lexical entries. + """ _ensure_loaded() token_clean = token.strip().lower() @@ -165,7 +197,7 @@ def lookup_unit(token: str) -> UnitEntry | None: singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "dollar" and right_unit.singular == "hour") - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, @@ -179,7 +211,7 @@ def lookup_unit(token: str) -> UnitEntry | None: singular = f"{left_unit.singular} per {r_sing}" plural = f"{left_unit.plural} per {r_sing}" is_canon = (left_unit.singular == "dollar" and r_sing == "item") - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, @@ -194,7 +226,7 @@ def lookup_unit(token: str) -> UnitEntry | None: singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "mile" and right_unit.singular == "hour") - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, @@ -209,7 +241,7 @@ def lookup_unit(token: str) -> UnitEntry | None: singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "pound" and right_unit.surface == "cubic foot") - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, @@ -227,7 +259,7 @@ def lookup_unit(token: str) -> UnitEntry | None: singular = f"square {sub_unit.singular}" plural = f"square {sub_unit.plural}" is_canon = (sub_unit.singular == "foot") - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, @@ -244,7 +276,7 @@ def lookup_unit(token: str) -> UnitEntry | None: if sub_unit and sub_unit.dimension == "length": singular = f"cubic {sub_unit.singular}" plural = f"cubic {sub_unit.plural}" - return UnitEntry( + return _inferred_unit_entry( surface=token, singular=singular, plural=plural, diff --git a/tests/test_adr_0085_gloss_aware_cause.py b/tests/test_adr_0085_gloss_aware_cause.py index 9ee00136..d08380c6 100644 --- a/tests/test_adr_0085_gloss_aware_cause.py +++ b/tests/test_adr_0085_gloss_aware_cause.py @@ -134,7 +134,7 @@ class TestRuntimeDispatch: _EXPECTED_COGNITION_METRICS = { "total": 13, "intent_accuracy": 1.0, - "term_capture_rate": 0.9167, + "term_capture_rate": 1.0, "surface_groundedness": 1.0, "versor_closure_rate": 1.0, } diff --git a/tests/test_epistemic_phase3_state_tagging.py b/tests/test_epistemic_phase3_state_tagging.py new file mode 100644 index 00000000..0dfed6c1 --- /dev/null +++ b/tests/test_epistemic_phase3_state_tagging.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import numpy as np + +from chat.telemetry import serialize_turn_event +from core.epistemic_state import EpistemicState, NormativeClearance +from core.physics.identity import TurnEvent +from language_packs.compiler import compile_entries_to_manifold +from language_packs.loader import lookup_unit +from language_packs.schema import LexicalEntry +from teaching.epistemic import EpistemicStatus +from vault.store import VaultStore, epistemic_state_for_vault_status + + +def test_turn_event_telemetry_serializes_state_axes() -> None: + event = TurnEvent( + turn=7, + input_tokens=("truth",), + surface="Truth is grounded.", + walk_surface="Truth is grounded.", + articulation_surface="Truth is grounded.", + dialogue_role="assert", + identity_score=None, + cycle_cost_total=0.0, + vault_hits=1, + versor_condition=0.0, + flagged=False, + grounding_source="pack", + epistemic_state=EpistemicState.DECODED.value, + normative_clearance=NormativeClearance.CLEARED.value, + normative_detail="all runtime-checkable verdicts upheld", + ) + + payload = serialize_turn_event(event) + + assert payload["epistemic_state"] == "decoded" + assert payload["normative_clearance"] == "cleared" + assert payload["normative_detail"] == "all runtime-checkable verdicts upheld" + + +def test_lookup_unit_tags_curated_and_composed_entries() -> None: + curated = lookup_unit("dollar") + # "euro per hour" is not a curated lexicon entry; it is dynamically + # composed from decoded primitives (euro × hour) by the per-unit rule. + inferred = lookup_unit("euro per hour") + + assert curated is not None + assert curated.epistemic_state == EpistemicState.DECODED.value + assert inferred is not None + assert inferred.epistemic_state == EpistemicState.INFERRED.value + + +def test_compile_entries_to_manifold_preserves_entry_status_mapping() -> None: + entries = [ + LexicalEntry( + entry_id="coherent-1", + surface="alpha", + lemma="alpha", + language="en", + semantic_domains=("test.domain",), + provenance_ids=("fixture",), + epistemic_status="coherent", + ), + LexicalEntry( + entry_id="speculative-1", + surface="beta", + lemma="beta", + language="en", + semantic_domains=("test.domain",), + provenance_ids=("fixture",), + epistemic_status="speculative", + ), + LexicalEntry( + entry_id="falsified-1", + surface="gamma", + lemma="gamma", + language="en", + semantic_domains=("test.domain",), + provenance_ids=("fixture",), + epistemic_status="falsified", + ), + ] + + manifold, _ = compile_entries_to_manifold(entries) + + assert manifold.epistemic_state_for_word("alpha") == EpistemicState.DECODED.value + assert manifold.epistemic_state_for_word("beta") == EpistemicState.UNVERIFIED_POSSIBLE.value + assert manifold.epistemic_state_for_word("gamma") == EpistemicState.CONTRADICTED.value + + +def test_vault_statuses_map_to_distinct_epistemic_states() -> None: + assert epistemic_state_for_vault_status(EpistemicStatus.COHERENT) is EpistemicState.DECODED + assert epistemic_state_for_vault_status(EpistemicStatus.FALSIFIED) is EpistemicState.CONTRADICTED + assert epistemic_state_for_vault_status(EpistemicStatus.SPECULATIVE) is EpistemicState.UNVERIFIED_POSSIBLE + assert epistemic_state_for_vault_status(EpistemicStatus.CONTESTED) is EpistemicState.AMBIGUOUS + + +def test_vault_recall_exposes_epistemic_state_metadata() -> None: + vault = VaultStore(reproject_interval=0) + v = np.zeros(32, dtype=np.float32) + v[0] = 1.0 + + vault.store(v, epistemic_status=EpistemicStatus.FALSIFIED) + + hits = vault.recall(v, top_k=1) + + assert len(hits) == 1 + assert hits[0]["epistemic_state"] == EpistemicState.CONTRADICTED.value + assert hits[0]["metadata"]["epistemic_state"] == EpistemicState.CONTRADICTED.value diff --git a/vault/store.py b/vault/store.py index 4cc0b887..fe8a46b5 100644 --- a/vault/store.py +++ b/vault/store.py @@ -17,6 +17,7 @@ from collections import deque import numpy as np from algebra.backend import vault_recall, vault_recall_batch from algebra.cga import null_project +from core.epistemic_state import EpistemicState from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus @@ -24,6 +25,19 @@ def _versor_key(F: np.ndarray) -> bytes: return np.asarray(F, dtype=np.float32).tobytes() +def epistemic_state_for_vault_status(entry_status: EpistemicStatus) -> EpistemicState: + """Map legacy vault review statuses onto the ratified state taxonomy.""" + if entry_status is EpistemicStatus.COHERENT: + return EpistemicState.DECODED + if entry_status is EpistemicStatus.FALSIFIED: + return EpistemicState.CONTRADICTED + if entry_status is EpistemicStatus.SPECULATIVE: + return EpistemicState.UNVERIFIED_POSSIBLE + if entry_status is EpistemicStatus.CONTESTED: + return EpistemicState.AMBIGUOUS + return EpistemicState.EPISTEMIC_STATE_NEEDED + + def _status_admits(entry_status: EpistemicStatus, min_status: EpistemicStatus) -> bool: """Return True iff `entry_status` is admissible at the `min_status` tier. @@ -31,7 +45,10 @@ def _status_admits(entry_status: EpistemicStatus, min_status: EpistemicStatus) - requested tier — they carry CONTRADICTED semantics and are retained only for provenance and Stage-3 inversion (ADR-0021 §3). SPECULATIVE entries are separately excluded at the COHERENT tier (UNVERIFIED-POSSIBLE semantics - — not yet coherent, but distinct from actively falsified). If the + — not yet coherent, but distinct from actively falsified). The + exclusion reason for each status is externally inspectable via + ``epistemic_state_for_vault_status``: FALSIFIED→CONTRADICTED, + SPECULATIVE→UNVERIFIED_POSSIBLE, CONTESTED→AMBIGUOUS. If the admissibility set grows in the future (it should not, per ADR-0021), only this helper changes. """ @@ -42,6 +59,17 @@ def _status_admits(entry_status: EpistemicStatus, min_status: EpistemicStatus) - return entry_status is min_status +def _parse_entry_status(raw_status: object) -> EpistemicStatus: + if isinstance(raw_status, EpistemicStatus): + return raw_status + if isinstance(raw_status, str): + try: + return EpistemicStatus(raw_status) + except ValueError: + return EpistemicStatus.SPECULATIVE + return EpistemicStatus.SPECULATIVE + + class VaultStore: def __init__( self, @@ -79,6 +107,7 @@ class VaultStore: arr = np.asarray(F, dtype=np.float32).copy() stamped: dict = dict(metadata) if metadata else {} stamped["epistemic_status"] = epistemic_status.value + stamped["epistemic_state"] = epistemic_state_for_vault_status(epistemic_status).value will_evict = self._max_entries is not None and len(self._versors) >= self._max_entries self._versors.append(arr) @@ -143,11 +172,9 @@ class VaultStore: if min_status is not None: filtered: list[tuple[int, float]] = [] for i, score in ranked: - raw_status = self._metadata[i].get("epistemic_status", "speculative") - try: - entry_status = EpistemicStatus(raw_status) - except ValueError: - entry_status = EpistemicStatus.SPECULATIVE + entry_status = _parse_entry_status( + self._metadata[i].get("epistemic_status", "speculative") + ) if _status_admits(entry_status, min_status): filtered.append((i, score)) ranked = filtered @@ -158,6 +185,9 @@ class VaultStore: "score": float(score), "metadata": self._metadata[i], "index": i, + "epistemic_state": epistemic_state_for_vault_status( + _parse_entry_status(self._metadata[i].get("epistemic_status", "speculative")) + ).value, } for i, score in ranked[:top_k] ] @@ -204,13 +234,9 @@ class VaultStore: if min_status is not None: filtered: list[tuple[int, float]] = [] for i, score in ranked: - raw_status = self._metadata[i].get( - "epistemic_status", "speculative", + entry_status = _parse_entry_status( + self._metadata[i].get("epistemic_status", "speculative") ) - try: - entry_status = EpistemicStatus(raw_status) - except ValueError: - entry_status = EpistemicStatus.SPECULATIVE if _status_admits(entry_status, min_status): filtered.append((i, score)) ranked = filtered @@ -221,6 +247,9 @@ class VaultStore: "score": float(score), "metadata": self._metadata[i], "index": i, + "epistemic_state": epistemic_state_for_vault_status( + _parse_entry_status(self._metadata[i].get("epistemic_status", "speculative")) + ).value, } for i, score in ranked[:top_k] ]) diff --git a/vocab/manifold.py b/vocab/manifold.py index c8927ede..0e0ce359 100644 --- a/vocab/manifold.py +++ b/vocab/manifold.py @@ -32,6 +32,7 @@ import numpy as np from algebra.backend import cga_inner from algebra.cl41 import geometric_product, reverse from algebra.versor import versor_unit_residual +from core.epistemic_state import EpistemicState, coerce_epistemic_state from core.physics.energy import EnergyProfile from core.physics.valence import ValenceBundle from language_packs.schema import MorphologyEntry @@ -72,6 +73,7 @@ class VocabManifold: self._language_by_word: dict[str, str] = {} self._energy_by_word: dict[str, EnergyProfile] = {} self._valence_by_word: dict[str, ValenceBundle] = {} + self._epistemic_state_by_word: dict[str, str] = {} self._transient_words: set[str] = set() self._unknown_token_log: list[dict[str, object]] = [] @@ -83,6 +85,7 @@ class VocabManifold: language: str | None = None, energy: EnergyProfile | None = None, valence: ValenceBundle | None = None, + epistemic_state: str | EpistemicState | None = None, ) -> None: """ Register a word-versor pair. @@ -97,6 +100,10 @@ class VocabManifold: algebra primitive. Do not call normalize_to_versor() directly; that function is reserved for the injection gate. + ``epistemic_state`` defaults to DECODED for compiled pack entries. + The compiler can override this when a lexical row's review status + maps to another ratified state. + Raises: ValueError: if the full unit-versor residual is not satisfied. """ @@ -104,6 +111,10 @@ class VocabManifold: _assert_manifold_versor(word, v) self._words.append(word) self._versors.append(v) + self._epistemic_state_by_word[word] = coerce_epistemic_state( + epistemic_state, + default=EpistemicState.DECODED, + ).value resolved_language = language or (morphology.language if morphology is not None else None) if resolved_language: self._language_by_word[word] = resolved_language @@ -128,7 +139,7 @@ class VocabManifold: if word in self._transient_words: self.update(word, versor) return - self.add(word, versor) + self.add(word, versor, epistemic_state=EpistemicState.UNVERIFIED_NOVEL) self._transient_words.add(word) def is_transient(self, word: str) -> bool: @@ -221,6 +232,16 @@ class VocabManifold: """Return ADR-0007 valence bundle for a stored surface, when available.""" return self._valence_by_word.get(word) + def epistemic_state_for_word(self, word: str) -> str: + """Return the ratified epistemic state for a stored surface. + + Missing state metadata defaults to UNDETERMINED rather than + silently treating the entry as decoded. + """ + if word not in self._words: + raise KeyError(f"Word '{word}' not in vocabulary.") + return self._epistemic_state_by_word.get(word, EpistemicState.UNDETERMINED.value) + def language_for_word(self, word: str) -> str | None: """Return the language code for a stored surface, if known.""" morphology = self._morphology_by_word.get(word)