checking system…
Docs / back / src/maf/arenas/mastermind/schema.py · line 121
Python · 162 lines
  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()