diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 46df449d..c3087845 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -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, ) diff --git a/core/cognition/result.py b/core/cognition/result.py index 87924f62..54973764 100644 --- a/core/cognition/result.py +++ b/core/cognition/result.py @@ -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 diff --git a/tests/test_compound_intent_substrate.py b/tests/test_compound_intent_substrate.py new file mode 100644 index 00000000..c9fa0580 --- /dev/null +++ b/tests/test_compound_intent_substrate.py @@ -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 == ()