1"""Typed wire contract for the ``mastermind`` arena's ``Decision`` envelope. 2 3This is the stable shape that the mastermind arena emits and that downstream 4consumers (Redis stream, dashboard, memory store, reflection pass) read. 5Defining it now — before T-0033's Neo4j wiring and T-0034's entity extractor 6land — pins the schema so producers and consumers don't drift. 7 8Style notes 9----------- 10- All models are ``frozen=True`` so a ``Decision`` round-tripped from JSON 11 cannot be mutated in-place by accident; outcome backfill returns a new 12 instance via ``model_copy(update=...)`` instead. 13- Bounds are expressed via ``Annotated[type, Field(...)]`` only; we do not 14 use ``field_validator`` decorators here. Same convention as 15 ``crowd_simulation/schema.py``. 16- We do **not** set ``strict=True`` (we burned ourselves on this in T-0017): 17 pydantic v2's default coercion handles ISO-8601 ``generated_at`` strings 18 cleanly so ``model_dump(mode="json") -> json.loads -> model_validate(dict)`` 19 round-trips cleanly across the Redis-stream emit/consume path. 20- ``ArgumentNode`` is recursive. We use the standard pydantic v2 forward-ref 21 pattern: forward-quoted ``list["ArgumentNode"]`` annotation plus an 22 explicit ``model_rebuild()`` at the bottom of the module so the type is 23 fully materialised even when imported lazily (e.g. by the demo script). 24- The schema is **not** trading-specific. The ``domain`` literal allows 25 ``"trading" | "research" | "ops" | "other"`` so the same envelope can carry 26 a "should we adopt this auth library" decision just as cleanly as a long 27 AAPL call. 28""" 29 30from __future__ import annotations 31 32from datetime import UTC, datetime 33from typing import Annotated, Any, Literal 34from uuid import uuid4 35 36from pydantic import BaseModel, ConfigDict, Field 37 38DomainLiteral = Literal["trading", "research", "ops", "other"] 39UsedHowLiteral = Literal["supporting", "contradicting", "analogous", "ignored"] 40 41 42def _utcnow() -> datetime: 43 """Factory for ``Decision.generated_at`` (UTC, timezone-aware).""" 44 return datetime.now(UTC) 45 46 47def _new_decision_id() -> str: 48 """Factory for ``Decision.decision_id`` — 32-char hex from uuid4.""" 49 return uuid4().hex 50 51 52class ArgumentNode(BaseModel): 53 """Recursive node in the judge's structured argument tree. 54 55 A ``claim`` is supported by zero-or-more ``premises`` and may be challenged 56 by zero-or-more ``counterarguments`` (each itself an :class:`ArgumentNode`, 57 so a counterargument can itself be counter-counter-argued). ``confidence`` 58 is the judge's belief in the claim *after* weighing the counterarguments, 59 expressed in [0, 1]. 60 """ 61 62 model_config = ConfigDict(frozen=True) 63 64 claim: Annotated[str, Field(min_length=1, max_length=2000)] 65 premises: list[str] = Field(default_factory=list) 66 counterarguments: list[ArgumentNode] = Field(default_factory=list) 67 confidence: Annotated[float, Field(ge=0.0, le=1.0)] 68 69 70class ArenaVote(BaseModel): 71 """One constituent arena's vote, as ingested by the mastermind judge. 72 73 ``correlation_id`` is optional and is the upstream arena's per-run 74 identifier (e.g. the Redis stream entry id) so we can trace the vote 75 back to the source arena's trail. 76 """ 77 78 model_config = ConfigDict(frozen=True) 79 80 arena: Annotated[str, Field(min_length=1)] 81 vote: Annotated[str, Field(min_length=1)] 82 weight: Annotated[float, Field(ge=0.0, le=1.0)] 83 rationale: Annotated[str, Field(max_length=500)] 84 correlation_id: str | None = None 85 86 87class MemoryCitation(BaseModel): 88 """A reference to a prior :class:`Decision` retrieved from memory. 89 90 The ``similarity`` is the hybrid score returned by ``DecisionMemory`` 91 (BM25 + chroma merged), in [0, 1]. ``used_how`` records the judge's 92 classification of the citation: did it back the recommendation, push 93 against it, merely rhyme, or get explicitly set aside? 94 """ 95 96 model_config = ConfigDict(frozen=True) 97 98 decision_id: Annotated[str, Field(min_length=1)] 99 similarity: Annotated[float, Field(ge=0.0, le=1.0)] 100 summary: Annotated[str, Field(max_length=300)] 101 used_how: UsedHowLiteral 102 103 104class GraphCitation(BaseModel): 105 """A reference to a knowledge-graph entity or relation consulted by the 106 mastermind during reasoning. 107 108 ``path_hops`` is bounded at 5 because anything beyond that on a small 109 Neo4j graph stops being interpretable to the judge (and tends to pull in 110 irrelevant entities). 111 """ 112 113 model_config = ConfigDict(frozen=True) 114 115 entity_id: Annotated[str, Field(min_length=1)] 116 entity_kind: Annotated[str, Field(min_length=1)] 117 relation: str | None = None 118 path_hops: Annotated[int, Field(ge=0, le=5)] = 1 119 120 121class Decision(BaseModel): 122 """The mastermind arena's primary output envelope. 123 124 Carries the recommendation plus full provenance: the structured argument 125 tree, per-arena votes, memory and graph citations, and (after a 126 reflection pass) the realised outcome and one-line lesson. 127 128 The schema is intentionally domain-agnostic so the same engine can answer 129 a trading question or a research question; the ``domain`` literal lets 130 consumers route accordingly. 131 """ 132 133 model_config = ConfigDict(frozen=True) 134 135 decision_id: str = Field(default_factory=_new_decision_id) 136 # The arena_id of the run that produced this decision. Empty for legacy 137 # entries loaded from before this field existed. Used by 138 # ``DecisionMemory.add_outcome`` to correlate execution outcomes back to 139 # the decision they came from without a separate index table. 140 arena_id: str = "" 141 question: Annotated[str, Field(min_length=1, max_length=2000)] 142 recommendation: Annotated[str, Field(min_length=1, max_length=200)] 143 confidence: Annotated[float, Field(ge=0.0, le=1.0)] 144 dissent_pct: Annotated[float, Field(ge=0.0, le=1.0)] 145 reasoning: Annotated[str, Field(max_length=4000)] 146 argument_tree: ArgumentNode 147 arena_votes: list[ArenaVote] = Field(default_factory=list) 148 memory_citations: list[MemoryCitation] = Field(default_factory=list) 149 graph_citations: list[GraphCitation] = Field(default_factory=list) 150 domain: DomainLiteral = "trading" 151 horizon: str | None = None 152 outcome: dict[str, Any] | None = None 153 lesson: Annotated[str, Field(max_length=500)] | None = None 154 generated_at: datetime = Field(default_factory=_utcnow) 155 meta: dict[str, Any] = Field(default_factory=dict) 156 157 158# Resolve the ``list["ArgumentNode"]`` forward reference so the type is fully 159# materialised regardless of import order. 160ArgumentNode.model_rebuild() 161Decision.model_rebuild()