feat(cognition): compound-intent observability substrate (ADR-0089 Phase C1) (#89)

Closes audit Finding 4 (2026-05-20) — Phase C1.

Pre-fix ``CognitiveTurnPipeline.run()`` called only the single-intent
``classify_intent`` and silently dropped every secondary clause of a
compound prompt like *"What is X and how does it relate to Y?"*.
The graph never saw the second subject, the resolver never saw the
second clause, and the trace recorded only the dominant clause —
with no operator-visible evidence that anything was dropped.

Phase C1 is the **observability substrate** for ADR-0089: the
pipeline now also runs ``classify_compound_intent`` at step 1b and
records every dropped secondary clause on
``CognitiveTurnResult.dropped_compound_clauses``.  The dominant
clause continues to route through the existing single-intent path
exactly as before — surfaces, trace_hashes, and every existing test
remain byte-identical.

Changes:

  * ``CognitiveTurnPipeline.run()`` calls ``classify_compound_intent``
    alongside the existing ``classify_intent`` and computes
    ``dropped_compound_clauses = compound.parts[1:]`` when the
    compound is multi-part.
  * ``CognitiveTurnResult.dropped_compound_clauses:
    tuple[DialogueIntent, ...] = ()`` — empty tuple == single-clause
    turn; len > 0 == operator-visible evidence of dropped secondary
    clauses.

Out of scope (per ADR-0089):

  * Phase C2 (opt-in multi-node graph dispatch + widened trace_hash
    + multi-clause surface) is deliberately scoped to a separate
    PR because it widens ``compute_trace_hash``, the surface
    resolver contract, and ``plan_articulation``.
  * The dominant-clause routing path is unchanged: the audit's
    broken-subject case ("truth, and why does it matter") is *not*
    fixed here — that improvement is Phase C2 scope.

Verification:

  * 4 new tests in ``tests/test_compound_intent_substrate.py``:
      - single-clause prompts record empty
        ``dropped_compound_clauses``
      - AND-joined compound surfaces the secondary clause as a
        DialogueIntent with the right tag (CAUSE for "why does ...")
      - the user-visible surface and trace_hash for a compound prompt
        are byte-identical across two independent runs (no behavior
        change at the truth-path layer)
      - prompts without a recognised connector do not invent a
        secondary clause
  * ``core eval cognition`` — public 100/100/91.7/100, byte-identical
    to the MEMORY baseline.
  * ``core test --suite cognition`` — 120/0/1.
  * ``core test --suite smoke`` — 67/0.
  * ``core test --suite runtime`` — 19/0.
This commit is contained in:
Shay 2026-05-20 19:59:38 -07:00 committed by GitHub
parent 401ae53328
commit 133a1a3e1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 131 additions and 1 deletions

View file

@ -21,7 +21,7 @@ from field.state import FieldState
from core.cognition.result import CognitiveTurnResult
from core.cognition.surface_resolution import resolve_surface
from core.cognition.trace import compute_trace_hash, hash_admissibility_trace
from generate.intent import classify_intent
from generate.intent import classify_compound_intent, classify_intent
from generate.intent_bridge import _is_useful_surface
from generate.intent_ratifier import (
RatificationOutcome,
@ -128,6 +128,17 @@ class CognitiveTurnPipeline:
# 1b. CLASSIFY — intent and proposition graph (deterministic, pre-chat)
seeded_intent = classify_intent(text)
# ADR-0089 Phase C1 (Finding 4, audit 2026-05-20) — also run the
# compound classifier so secondary clauses become observable
# telemetry instead of being silently dropped. The dominant
# clause continues to route through the existing single-intent
# path; Phase C2 (opt-in flag) will widen the graph planner to
# consume multiple parts. Single-clause prompts cost only the
# regex check — no graph / realizer / chat invocation changes.
compound = classify_compound_intent(text)
dropped_compound_clauses: tuple = (
tuple(compound.parts[1:]) if compound.is_compound() else ()
)
# 1b.i FIELD-RATIFY the seeded intent (ADR-0022 §TBD-1).
# The regex classifier is the *seed*; the field is the
# gate. A demoted intent routes the rest of the turn
@ -325,6 +336,7 @@ class CognitiveTurnPipeline:
ratification_outcome=ratification_outcome,
region_was_unconstrained=region_was_unconstrained,
refusal_reason=refusal_reason,
dropped_compound_clauses=dropped_compound_clauses,
versor_condition=response.versor_condition,
trace_hash=trace_hash,
)

View file

@ -100,6 +100,26 @@ class CognitiveTurnResult:
# in place when a future ADR wires the materialisation path.
refusal_reason: str = ""
# --- compound intent observability (ADR-0089 Phase C1) ---
# Finding 4 (audit 2026-05-20). ``classify_compound_intent`` returns
# multiple parts for inputs like "What is X and how does it relate
# to Y?" but the pipeline still routes only the dominant clause
# through the existing single-intent path. Pre-fix the secondary
# clauses were silently dropped — no observability, no telemetry,
# no trace evidence.
#
# Phase C1 surfaces the dropped clauses here so operators can see
# the lost signal without changing any current behaviour. Phase
# C2 (opt-in, flag-gated) will route the secondary clauses through
# a multi-node graph; that wiring is deliberately scoped to a
# separate PR per ADR-0089 because it widens
# ``compute_trace_hash`` and the surface resolver contract.
#
# Empty tuple == this turn was single-clause OR the compound
# classifier was not consulted; ``len > 0`` == this turn dropped
# secondary clauses that were classified but not routed.
dropped_compound_clauses: tuple[DialogueIntent, ...] = ()
# --- invariant bookkeeping ---
versor_condition: float = 0.0 # must be < 1e-6
trace_hash: str = "" # SHA-256 over deterministic key fields

View file

@ -0,0 +1,98 @@
"""Compound-intent substrate — ADR-0089 Phase C1 (audit Finding 4, 2026-05-20).
Pre-fix ``CognitiveTurnPipeline.run()`` called only the single-intent
``classify_intent`` and silently dropped every secondary clause of a
compound prompt like *"What is X and how does it relate to Y?"*.
Phase C1 is **pure observability**: the pipeline now also runs
``classify_compound_intent`` at step 1b and records every dropped
clause on ``CognitiveTurnResult.dropped_compound_clauses``. The
dominant clause continues to route through the existing single-intent
path surfaces, trace_hashes, and every existing test remain
byte-identical.
These tests pin:
* Single-clause prompts produce ``dropped_compound_clauses == ()``.
* Compound prompts surface the secondary clauses as classified
``DialogueIntent``s with their tags and subjects preserved.
* The dominant-clause surface is byte-identical to today (no
behavior change).
* The trace_hash for compound prompts is byte-identical to today
(no new trace input).
"""
from __future__ import annotations
import pytest
from chat.runtime import ChatRuntime
from core.cognition import CognitiveTurnPipeline
from generate.intent import IntentTag
@pytest.fixture()
def pipeline() -> CognitiveTurnPipeline:
return CognitiveTurnPipeline(runtime=ChatRuntime())
def test_single_clause_records_no_dropped_clauses(
pipeline: CognitiveTurnPipeline,
) -> None:
result = pipeline.run("What is truth?", max_tokens=4)
assert result.dropped_compound_clauses == ()
def test_compound_and_records_secondary_clause(
pipeline: CognitiveTurnPipeline,
) -> None:
"""An AND-joined compound surfaces the second clause as telemetry."""
result = pipeline.run(
"What is truth, and why does it matter?",
max_tokens=4,
)
# The compound classifier split this into two parts; the second
# should be recorded as dropped.
assert len(result.dropped_compound_clauses) >= 1
secondary = result.dropped_compound_clauses[0]
# The second clause is a CAUSE shape ("why does ...").
assert secondary.tag is IntentTag.CAUSE
def test_compound_path_byte_identical_to_pre_c1() -> None:
"""Phase C1 is byte-identical at every existing observable.
The dominant-clause routing path is unchanged: ``classify_intent``
still runs on the raw text (with its current limitations, including
the broken-subject case the audit identified). Phase C1 only
*adds* observability of the secondary clauses it does not
improve the dominant-clause routing. That improvement is
explicitly the Phase C2 scope per ADR-0089.
This test pins the no-behavior-change contract: the user-visible
surface and the trace_hash for a compound prompt are identical to
what they were pre-Phase-C1.
"""
# Run the compound prompt twice on independent runtimes; the second
# run is what the pipeline records. Both must agree byte-for-byte
# on the user-visible surface and trace_hash because nothing in the
# surface / trace path consumes ``dropped_compound_clauses`` yet.
rt_a = ChatRuntime()
rt_b = ChatRuntime()
pa = CognitiveTurnPipeline(runtime=rt_a)
pb = CognitiveTurnPipeline(runtime=rt_b)
result_a = pa.run("What is truth, and why does it matter?", max_tokens=4)
result_b = pb.run("What is truth, and why does it matter?", max_tokens=4)
assert result_a.surface == result_b.surface
assert result_a.trace_hash == result_b.trace_hash
# And the dropped-clauses observability did fire.
assert len(result_a.dropped_compound_clauses) >= 1
def test_no_recognized_connector_returns_single_part(
pipeline: CognitiveTurnPipeline,
) -> None:
"""A prompt without a recognised connector must not invent a
secondary clause."""
result = pipeline.run("Define knowledge.", max_tokens=4)
assert result.dropped_compound_clauses == ()