core/chat/atom_equivalence.py
Shay 8e96728009 feat(telemetry): ADR-0078 Phase 1 — composer/graph atom equivalence (observational)
Wires observational telemetry on the composer-vs-graph atom-set
relationship.  Phase 1 is strictly observational: no enforcement,
no surface mutation, no grounding-source change, no trace-hash impact.

New telemetry fields on TurnEvent + ChatResponse:
  composer_graph_atom_status         ∈ {equivalent, divergent,
                                         graph_unconstrained,
                                         composer_no_atoms,
                                         not_applicable, ""}
  composer_atom_set_hash             SHA-256 over sorted unique atoms
  graph_atom_set_hash                SHA-256 over sorted unique atoms
  composer_graph_atom_overlap_count  int

Composer atoms come from existing pack candidate metadata
(pack_semantic_domains channel through _maybe_pack_grounded_surface).
Graph atoms come from build_graph_from_input + resolve_lemma on
node.subject/predicate/obj — no prose parsing.  When a grounded
composer path lacks explicit atom provenance, status is
'composer_no_atoms'.

New pure helper:
  chat/atom_equivalence.py — normalize_atoms, hash_atoms,
  atoms_for_graph_nodes, compare_atom_sets

Tests (tests/test_composer_graph_atom_equivalence.py):
  - Pack DEFINITION path produces observable equivalence
  - Divergent atom sets produce distinct hashes
  - Register invariance: atom hashes + status identical across
    {neutral, terse, convivial}; trace_hash also constant (R5 axis)
  - Anchor lens engaged case still ASCII-only on surface
  - No prose-parsing helper symbols introduced in runtime.py
    (extract_candidate_surface_lemmas, surface_lemma,
    parse_surface_atoms) — enforces Phase 1 boundary

Performance note: build_graph_from_input now runs on every warm
English turn (previously only when forward_graph_constraint=True).
Phase 1 accepts this cost to make the telemetry universally
available; Phase 2+ can introduce a feature flag if needed.

Validation:
  - Cognition eval byte-identical: 100/100/91.7/100
  - Full lane: 2864 passed, 3 skipped, 0 failed (+5 over baseline)
  - Targeted lane: 72 passed in tests/test_{graph_constraint,
    pack_grounding,register_tour_demo,anchor_lens_tour_demo,
    orthogonality_tour_demo,realizer_guard_holdout,
    composer_graph_atom_equivalence}.py
2026-05-20 06:14:25 -07:00

80 lines
2.1 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
from chat.pack_resolver import resolve_lemma
@dataclass(frozen=True, slots=True)
class AtomEquivalence:
status: str
composer_atom_set_hash: str
graph_atom_set_hash: str
overlap_count: int
def normalize_atoms(atoms: tuple[str, ...]) -> tuple[str, ...]:
cleaned = sorted(
{
atom.strip()
for atom in atoms
if atom and atom.strip() and atom.strip() != "<pending>"
}
)
return tuple(cleaned)
def hash_atoms(atoms: tuple[str, ...]) -> str:
cleaned = normalize_atoms(atoms)
if not cleaned:
return ""
blob = "\n".join(cleaned).encode("utf-8")
return sha256(blob).hexdigest()
def atoms_for_graph_nodes(graph) -> tuple[str, ...]:
atoms: list[str] = []
for node in getattr(graph, "nodes", ()) or ():
for surface in (
getattr(node, "subject", ""),
getattr(node, "predicate", ""),
getattr(node, "obj", ""),
):
resolved = resolve_lemma(str(surface))
if resolved is None:
continue
_, domains = resolved
atoms.extend(domains)
return normalize_atoms(tuple(atoms))
def compare_atom_sets(
*,
composer_atoms: tuple[str, ...],
graph_atoms: tuple[str, ...],
graph_unconstrained: bool,
applicable: bool,
) -> AtomEquivalence:
composer_norm = normalize_atoms(composer_atoms)
graph_norm = normalize_atoms(graph_atoms)
composer_hash = hash_atoms(composer_norm)
graph_hash = hash_atoms(graph_norm)
overlap = len(set(composer_norm).intersection(graph_norm))
if not applicable:
status = "not_applicable"
elif not composer_norm:
status = "composer_no_atoms"
elif graph_unconstrained or not graph_norm:
status = "graph_unconstrained"
elif overlap > 0:
status = "equivalent"
else:
status = "divergent"
return AtomEquivalence(
status=status,
composer_atom_set_hash=composer_hash,
graph_atom_set_hash=graph_hash,
overlap_count=overlap,
)