"""Contract tests for ``generate/discourse_planner.py``. These tests pin the **serialization determinism** invariant that a later ADR will rely on when folding ``DiscoursePlan`` into ``compute_trace_hash``. Adding the determinism gate *before* the hash dependency is the explicit sequencing requirement: replay regressions must surface as clean test failures, not as flaky paragraph turns downstream. What this file is **not**: * Not a runtime/wiring test — nothing here imports ``chat.*`` or exercises a live ``ChatRuntime``. At this stage the planner has no runtime path; cognition-eval byte-identity is asserted by the existing eval lane, not here. * Not a planner-behavior test — ``plan_discourse`` is contract-only in this landing and raises ``NotImplementedError``. Move-selection rules will be tested in the follow-up ADR. """ from __future__ import annotations import inspect import json import pytest from generate.discourse_planner import ( DialogueIntent, DiscourseMove, DiscourseMoveKind, DiscoursePlan, FactSource, GroundedFact, GroundingBundle, IntentTag, Relation, ResponseMode, plan_discourse, ) def _make_intent() -> DialogueIntent: return DialogueIntent(tag=IntentTag.DEFINITION, subject="truth") def _make_facts() -> tuple[GroundedFact, ...]: return ( GroundedFact( subject="truth", predicate="reveals", obj="knowledge", source=FactSource.TEACHING, source_id="cognition_chains_v1#cause_truth_reveals_knowledge", ), GroundedFact( subject="truth", predicate="is_defined_as", obj="that which corresponds to reality", source=FactSource.PACK, source_id="en_core_cognition_v1:truth", ), GroundedFact( subject="truth", predicate="belongs_to", obj="epistemic_domain", source=FactSource.PACK, source_id="en_core_cognition_v1:truth#domain", ), ) def _make_plan() -> DiscoursePlan: facts = _make_facts() # pack fact[1] is the anchor (is_defined_as), pack fact[2] supports # (belongs_to), teaching fact[0] is the relation. moves = ( DiscourseMove( kind=DiscourseMoveKind.ANCHOR, topic="truth", given=(), new=("truth",), relation_to_previous=None, fact=facts[1], ), DiscourseMove( kind=DiscourseMoveKind.SUPPORT, topic="truth", given=("truth",), new=("epistemic_domain",), relation_to_previous=Relation.ELABORATION, fact=facts[2], ), DiscourseMove( kind=DiscourseMoveKind.RELATION, topic="truth", given=("truth", "epistemic_domain"), new=("knowledge",), relation_to_previous=Relation.CAUSE, fact=facts[0], ), ) return DiscoursePlan( intent=_make_intent(), mode=ResponseMode.PARAGRAPH, moves=moves, ) # --------------------------------------------------------------------------- # Immutability / frozen-dataclass invariants # --------------------------------------------------------------------------- class TestFrozenInvariants: def test_grounded_fact_is_frozen(self) -> None: fact = _make_facts()[0] with pytest.raises((AttributeError, TypeError)): fact.subject = "different" # type: ignore[misc] def test_grounding_bundle_is_frozen(self) -> None: bundle = GroundingBundle(facts=_make_facts()) with pytest.raises((AttributeError, TypeError)): bundle.facts = () # type: ignore[misc] def test_discourse_move_is_frozen(self) -> None: plan = _make_plan() with pytest.raises((AttributeError, TypeError)): plan.moves[0].topic = "lie" # type: ignore[misc] def test_discourse_plan_is_frozen(self) -> None: plan = _make_plan() with pytest.raises((AttributeError, TypeError)): plan.mode = ResponseMode.BRIEF # type: ignore[misc] def test_value_equality(self) -> None: assert _make_plan() == _make_plan() assert hash(_make_facts()[0]) == hash(_make_facts()[0]) # --------------------------------------------------------------------------- # Canonical ordering invariants # --------------------------------------------------------------------------- class TestCanonicalOrdering: def test_fact_source_priority_is_pack_first(self) -> None: bundle = GroundingBundle( facts=( GroundedFact("a", "p", "b", FactSource.OPERATOR, "op:1"), GroundedFact("a", "p", "b", FactSource.VAULT, "vault:1"), GroundedFact("a", "p", "b", FactSource.TEACHING, "teach:1"), GroundedFact("a", "p", "b", FactSource.PACK, "pack:1"), ) ) sources = tuple(f.source for f in bundle.sorted_facts()) assert sources == ( FactSource.PACK, FactSource.TEACHING, FactSource.VAULT, FactSource.OPERATOR, ) def test_sort_is_total_within_same_source(self) -> None: bundle = GroundingBundle( facts=( GroundedFact("zeta", "p", "o", FactSource.PACK, "id:2"), GroundedFact("alpha", "p", "o", FactSource.PACK, "id:1"), GroundedFact("alpha", "p", "o", FactSource.PACK, "id:0"), ) ) ordered = bundle.sorted_facts() assert ordered[0].subject == "alpha" assert ordered[0].source_id == "id:0" assert ordered[1].subject == "alpha" assert ordered[1].source_id == "id:1" assert ordered[2].subject == "zeta" def test_bundle_sort_is_idempotent(self) -> None: bundle = GroundingBundle(facts=_make_facts()) once = bundle.sorted_facts() twice = GroundingBundle(facts=once).sorted_facts() assert once == twice def test_facts_by_source_filters_and_orders(self) -> None: bundle = GroundingBundle(facts=_make_facts()) pack_only = bundle.facts_by_source(FactSource.PACK) assert all(f.source is FactSource.PACK for f in pack_only) assert pack_only == tuple( sorted(pack_only, key=GroundedFact.sort_key) ) # --------------------------------------------------------------------------- # Serialization determinism (the gate before trace_hash adoption) # --------------------------------------------------------------------------- class TestSerializationDeterminism: def test_to_json_is_byte_stable_across_calls(self) -> None: plan = _make_plan() encoded = [plan.to_json() for _ in range(8)] assert len(set(encoded)) == 1 def test_to_json_is_byte_stable_across_equal_plans(self) -> None: a = _make_plan().to_json() b = _make_plan().to_json() assert a == b def test_to_json_uses_sorted_keys(self) -> None: encoded = _make_plan().to_json() decoded = json.loads(encoded) # Re-encoding with sort_keys must round-trip byte-identical. reencoded = json.dumps(decoded, sort_keys=True, separators=(",", ":")) assert encoded == reencoded def test_to_json_has_no_whitespace(self) -> None: encoded = _make_plan().to_json() assert ", " not in encoded assert ": " not in encoded def test_as_dict_round_trip_through_json(self) -> None: plan = _make_plan() decoded = json.loads(plan.to_json()) assert decoded["intent"]["tag"] == "definition" assert decoded["intent"]["subject"] == "truth" assert decoded["mode"] == "paragraph" assert len(decoded["moves"]) == 3 assert decoded["moves"][0]["kind"] == "anchor" assert decoded["moves"][0]["relation_to_previous"] is None assert decoded["moves"][1]["relation_to_previous"] == "elaboration" assert decoded["moves"][2]["relation_to_previous"] == "cause" assert decoded["moves"][0]["fact"]["source"] == "pack" def test_grounded_fact_as_dict_has_object_key_not_obj(self) -> None: fact = _make_facts()[0] encoded = fact.as_dict() assert "object" in encoded assert "obj" not in encoded def test_bundle_as_dict_is_sorted(self) -> None: unordered = ( GroundedFact("z", "p", "o", FactSource.OPERATOR, "op:0"), GroundedFact("a", "p", "o", FactSource.PACK, "pack:0"), ) bundle = GroundingBundle(facts=unordered) encoded = bundle.as_dict() facts_out = encoded["facts"] assert isinstance(facts_out, tuple) assert facts_out[0]["source"] == "pack" assert facts_out[1]["source"] == "operator" # --------------------------------------------------------------------------- # Plan-level shape helpers # --------------------------------------------------------------------------- class TestPlanHelpers: def test_empty_plan_reports_empty(self) -> None: plan = DiscoursePlan( intent=_make_intent(), mode=ResponseMode.BRIEF, ) assert plan.is_empty() assert plan.anchor() is None assert plan.topics() == () def test_anchor_returns_first_anchor_move(self) -> None: plan = _make_plan() anchor = plan.anchor() assert anchor is not None assert anchor.kind is DiscourseMoveKind.ANCHOR assert anchor.topic == "truth" def test_topics_preserves_first_introduction_order(self) -> None: plan = _make_plan() assert plan.topics() == ("truth",) def test_empty_bundle_helpers(self) -> None: bundle = GroundingBundle() assert bundle.is_empty() assert bundle.sorted_facts() == () assert bundle.facts_by_source(FactSource.PACK) == () # --------------------------------------------------------------------------- # Planner function signature is pure and contract-only # --------------------------------------------------------------------------- class TestPlannerSignature: def test_plan_discourse_signature(self) -> None: # ``from __future__ import annotations`` makes annotations strings; # resolve them via ``get_type_hints`` for an identity comparison. from typing import get_type_hints sig = inspect.signature(plan_discourse) # Public positional signature is (intent, mode, bundle). Any # keyword-only parameters added later (e.g. ``_exclude_facts`` # for sub-plan composition) must remain keyword-only with a # leading underscore so they are not part of the public API. positional_params = [ name for name, p in sig.parameters.items() if p.kind in ( inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, ) ] assert positional_params == ["intent", "mode", "bundle"] keyword_only = [ name for name, p in sig.parameters.items() if p.kind is inspect.Parameter.KEYWORD_ONLY ] for name in keyword_only: assert name.startswith("_"), ( f"keyword-only parameter {name!r} must be underscore-prefixed" ) hints = get_type_hints(plan_discourse) assert hints["intent"] is DialogueIntent assert hints["mode"] is ResponseMode assert hints["bundle"] is GroundingBundle assert hints["return"] is DiscoursePlan def test_plan_discourse_handles_empty_bundle(self) -> None: # Empty bundle ⇒ empty plan (planner is total; callers fall # through to the existing single-sentence composer path). plan = plan_discourse( _make_intent(), ResponseMode.PARAGRAPH, GroundingBundle(), ) assert plan.is_empty() assert plan.intent == _make_intent() assert plan.mode is ResponseMode.PARAGRAPH def test_no_runtime_imports(self) -> None: import generate.discourse_planner as dp src = inspect.getsource(dp) assert "from chat" not in src assert "import chat" not in src # No clock reads, no env reads, no filesystem. assert "time.time" not in src assert "datetime.now" not in src assert "os.environ" not in src assert "open(" not in src