Extends ADR-0022 with inspection/telemetry surfaces that turn the forward-semantic-control claim from "mechanism exists" into "mechanism is causally load-bearing, isolated, and replayable." Changes (zero runtime semantics change beyond a pipeline bug fix): - AdmissibilityTraceStep + GenerationResult.admissibility_trace — per-transition record of region label, candidates before/after, selected destination, and the typed AdmissibilityVerdict. - ChatResponse + CognitiveTurnResult expose admissibility_trace, admissibility_trace_hash, ratification_outcome, region_was_unconstrained. - hash_admissibility_trace + compute_trace_hash fold the new fields only when they carry non-default values, so pre-ADR-0023 turn hashes remain byte-preserved. - Same-path ablation leg in evals/forward_semantic_control/runner.py: generate(..., region=None) vs generate(..., region=R) on the same runtime/vocab/field/persona/prompt — isolates the region as cause. - Lane expansion: 8 dev cases across 4 relation axes (cause, means, precedes, part_of) including 2 adversarial distractor cases. - Lane metrics now report region_only_constrained_rate / region_only_gap / ratified_rate / demoted_rate / passthrough_rate / passthrough_on_scored. - Bug fix surfaced by the new accounting: _ratify_intent looked up runtime.vocab (always None) instead of runtime.session.vocab — every production turn was silently PASSTHROUGH. Fixed; ratifier now actually gates intent classification. - tests/test_admissibility_trace.py: hash determinism + pre-ADR-0023 byte-preservation tests. Lane evidence (dev, 8 cases): - constrained_pass_rate=0.80, causality_gap=0.80 - region_only_gap=1.00 (5/5 with region, 0/5 without — same path) - ratified_rate=1.00, passthrough_on_scored=false - overall_pass=true Bench: 9.41s / 20 turns (~470ms/turn), well inside the +5% budget. Full pytest: 922 passed, 1 pre-existing failure (test_language_pack_cache, unrelated to ADR-0023).
47 lines
2 KiB
Python
47 lines
2 KiB
Python
"""
|
|
GenerationResult — the complete output of one generation pass.
|
|
|
|
Generate() must return the evolved field state, not only surface tokens.
|
|
The field state after generation is semantically different from the
|
|
field state before generation; discarding it means the vault stores
|
|
the prompt field, not the assistant response field.
|
|
|
|
Contracts:
|
|
tokens — the decoded token sequence in emission order
|
|
final_state — FieldState after the last propagation step
|
|
trajectory — optional ordered list of intermediate FieldStates;
|
|
None unless the caller explicitly requests it (expensive)
|
|
vault_hits — exact number of vault recall hits applied during generation
|
|
identity_score — IdentityScore from IdentityCheck; None if not evaluated
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
from field.state import FieldState
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class GenerationResult:
|
|
tokens: tuple # decoded token sequence, immutable
|
|
final_state: FieldState
|
|
trajectory: tuple | None = None # (FieldState, ...) or None
|
|
salience_top_k: int | None = None
|
|
candidates_used: int | None = None
|
|
vault_hits: int = 0
|
|
identity_score: Optional[object] = None # IdentityScore | None
|
|
# ADR-0023 §2 — per-transition admissibility evidence. Always a
|
|
# tuple (possibly empty when no admissibility was checked).
|
|
admissibility_trace: tuple = field(default_factory=tuple)
|
|
region_was_unconstrained: bool = True
|
|
|
|
def __post_init__(self) -> None:
|
|
# Coerce list inputs to tuple for immutability.
|
|
object.__setattr__(self, "tokens", tuple(self.tokens))
|
|
if self.trajectory is not None:
|
|
object.__setattr__(self, "trajectory", tuple(self.trajectory))
|
|
object.__setattr__(self, "admissibility_trace", tuple(self.admissibility_trace))
|
|
|
|
def text(self, sep: str = " ") -> str:
|
|
"""Join tokens into a string for display."""
|
|
return sep.join(self.tokens)
|