1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""Typed contracts shared by the data layer, the agents, the debate protocols and the evaluation.34Conventions5-----------6* Money in CAD, areas in m2, distances in km, dates ISO (YYYY-MM-DD).7* Every piece of evidence shown to an agent carries an ``evidence_id`` (``P`` = subject property,8 ``C01..`` = comparables, ``M*`` = market context, ``G*`` = geospatial, ``R*`` = roll/assessment).9 Agents must cite these ids; the evaluator checks that cited ids exist in the packet.10* The subject's transaction price is NEVER part of an evidence packet.11"""12from __future__ import annotations1314import hashlib15import json16from datetime import date17from typing import Any, Literal1819from pydantic import BaseModel, Field, field_validator, model_validator2021PropertyClass = Literal["house", "condo", "plex"]22Subtype = Literal["detached", "semi", "row", "integrated", "unknown"]232425class SubjectProperty(BaseModel):26 """What is known about the subject *as of the valuation date* (no price)."""2728 evidence_id: str = "P"29 tx_id: str30 valuation_date: date31 information_cutoff: date = Field(description="Agents may only use evidence dated on or before this date")32 property_class: PropertyClass33 subtype: Subtype = "unknown"34 region: str35 municipality: str36 borough: str | None = None37 lat: float = Field(description="rounded to 3 decimals (~100 m) for privacy")38 lng: float39 floor_area_m2: float | None = None40 land_area_m2: float | None = None41 year_built: int | None = None42 storeys: float | None = None43 n_units: int | None = None44 roll_land_value: float | None = None45 roll_building_value: float | None = None46 roll_total_value: float | None = None47 roll_reference_date: str | None = Field(default=None, description="market reference date of the roll in force (e.g. 2022-07-01)")48 prior_sale_price: float | None = Field(default=None, description="most recent *earlier* sale of the same parcel, if any (strictly before cutoff)")49 prior_sale_date: date | None = None50 new_construction: bool | None = None51 missing_fields: list[str] = Field(default_factory=list)525354class Comparable(BaseModel):55 evidence_id: str = Field(pattern=r"^C\d{2}$")56 sale_date: date57 price: float58 distance_km: float59 property_class: PropertyClass60 subtype: Subtype = "unknown"61 municipality: str62 floor_area_m2: float | None = None63 land_area_m2: float | None = None64 year_built: int | None = None65 storeys: float | None = None66 n_units: int | None = None67 roll_total_value: float | None = None68 price_per_m2: float | None = None69 assessment_ratio: float | None = Field(default=None, description="price / roll_total_value")70 months_before_valuation: float717273class MarketContext(BaseModel):74 evidence_id: str = "M1"75 as_of: date76 municipality_median_price_12m: float | None = None77 municipality_median_ppm2_12m: float | None = None78 municipality_n_sales_12m: int | None = None79 municipality_price_index: list[dict[str, Any]] = Field(default_factory=list, description="[{month, index}] up to as_of (base = first month of sample)")80 region_median_price_12m: float | None = None81 region_yoy_change: float | None = None82 local_liquidity_n_sales_3km_12m: int | None = None83 local_median_assessment_ratio_12m: float | None = Field(default=None, description="median price/roll ratio within 3 km over the last 12 months")84 mortgage_rate_5y_fixed: float | None = Field(default=None, description="not available in this snapshot unless loaded from an external series")858687class GeoContext(BaseModel):88 evidence_id: str = "G1"89 in_flood_zone: bool | None = None90 transit_stops_within_1km: int | None = None91 da_population_density: float | None = None92 da_median_income: float | None = None93 distance_to_cbd_km: float | None = None94 notes: list[str] = Field(default_factory=list)959697class EvidencePacket(BaseModel):98 packet_id: str99 subject: SubjectProperty100 comparables: list[Comparable]101 market: MarketContext102 geo: GeoContext | None = None103 role_visible_sections: list[str] = Field(default_factory=lambda: ["subject", "comparables", "market", "geo"])104 notes: list[str] = Field(default_factory=list)105106 def evidence_ids(self) -> set[str]:107 ids = {self.subject.evidence_id, self.market.evidence_id}108 ids |= {c.evidence_id for c in self.comparables}109 if self.geo is not None:110 ids.add(self.geo.evidence_id)111 return ids112113 def content_hash(self) -> str:114 return hashlib.sha256(self.model_dump_json(exclude={"packet_id"}).encode()).hexdigest()[:16]115116117class Adjustment(BaseModel):118 comparable_id: str119 adjusted_price: float = Field(description="comparable price after all adjustments, CAD")120 reason: str121122123class AgentEstimate(BaseModel):124 """Structured output every agent must return (the JSON contract)."""125126 point_estimate: float = Field(description="most likely market value in CAD as of the valuation date")127 q05: float128 q25: float129 q50: float130 q75: float131 q95: float132 evidence_ids_used: list[str] = Field(description="ids from the packet actually relied upon")133 adjustments: list[Adjustment] = Field(default_factory=list)134 key_assumptions: list[str] = Field(default_factory=list)135 missing_data: list[str] = Field(default_factory=list)136 confidence_rationale: str = ""137 counterarguments: list[str] = Field(default_factory=list)138 reasoning_summary: str = Field(default="", description="<= 120 words")139140 @model_validator(mode="after")141 def _check_quantiles(self) -> "AgentEstimate":142 qs = [self.q05, self.q25, self.q50, self.q75, self.q95]143 if any(q <= 0 for q in qs) or self.point_estimate <= 0:144 raise ValueError("estimates must be positive")145 if any(b < a for a, b in zip(qs, qs[1:])):146 raise ValueError("quantiles must be non-decreasing")147 if not (self.q05 <= self.point_estimate <= self.q95):148 raise ValueError("point estimate must lie within [q05, q95]")149 return self150151152class Critique(BaseModel):153 target_role: str154 agree: bool155 points: list[str]156 evidence_ids_cited: list[str] = Field(default_factory=list)157 suggested_value: float | None = None158159160class CritiqueSet(BaseModel):161 critiques: list[Critique]162163164class AgentTurn(BaseModel):165 role: str166 round: int167 kind: Literal["estimate", "critique", "revision"]168 model: str169 provider: str170 estimate: AgentEstimate | None = None171 critiques: CritiqueSet | None = None172 input_tokens: int = 0173 output_tokens: int = 0174 cached_tokens: int = 0175 cost_usd: float = 0.0176 latency_s: float = 0.0177 prompt_hash: str = ""178 request_id: str | None = None179 validation_retries: int = 0180 error: str | None = None181182183class DebateTranscript(BaseModel):184 tx_id: str185 arm: str186 committee: str187 protocol: str188 rounds: int189 packet_hash: str190 turns: list[AgentTurn]191 final_estimates: dict[str, float] = Field(default_factory=dict, description="role -> final point estimate")192 final_quantiles: dict[str, list[float]] = Field(default_factory=dict, description="role -> [q05,q25,q50,q75,q95]")193 aggregated: dict[str, float] = Field(default_factory=dict, description="aggregation rule -> value")194 aggregated_interval: dict[str, list[float]] = Field(default_factory=dict, description="rule -> [lo90, hi90]")195 total_cost_usd: float = 0.0196 total_latency_s: float = 0.0197 total_tokens: int = 0198 assignment: dict[str, str] = Field(default_factory=dict, description="role -> model id (committee assignment, recorded per property)")199 notes: list[str] = Field(default_factory=list, description="protocol events: stopped_early_at=<r>, verification flags, blinded skeptic, ...")200201 def to_json(self) -> str:202 return self.model_dump_json(indent=1)203204205def canonical_json(obj: Any) -> str:206 return json.dumps(obj, sort_keys=True, ensure_ascii=False, default=str)207