Skip to content

Commit 01d25f8

Browse files
committed
feat: advance TWM simulator validation roadmap
1 parent 60029db commit 01d25f8

25 files changed

Lines changed: 59638 additions & 217 deletions

data_agent/api/territory_world_model_routes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,26 @@ async def twm_geofm_ablation_gate(request: Request):
525525
return JSONResponse({"error": str(exc)}, status_code=400)
526526

527527

528+
async def twm_geofm_downstream_experiment_report(request: Request):
529+
user = _get_user_from_request(request)
530+
if not user:
531+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
532+
_set_user_context(user)
533+
svc = get_territory_world_model_service()
534+
try:
535+
body = await request.json()
536+
except Exception:
537+
body = {}
538+
if not isinstance(body, dict):
539+
return JSONResponse({"error": "JSON body must be an object"}, status_code=400)
540+
try:
541+
return JSONResponse(svc.geofm_downstream_experiment_report(request.path_params["id"], body))
542+
except LookupError as exc:
543+
return JSONResponse({"error": str(exc)}, status_code=404)
544+
except Exception as exc:
545+
return JSONResponse({"error": str(exc)}, status_code=400)
546+
547+
528548
async def twm_causal_calibration_report(request: Request):
529549
user = _get_user_from_request(request)
530550
if not user:
@@ -573,6 +593,7 @@ def get_territory_world_model_routes() -> list[Route]:
573593
Route("/api/twm/states/{id}/fit-dynamics-candidate", endpoint=twm_fit_dynamics_candidate, methods=["POST"]),
574594
Route("/api/twm/states/{id}/train-dynamics-candidate", endpoint=twm_train_dynamics_candidate, methods=["POST"]),
575595
Route("/api/twm/states/{id}/geofm-ablation-gate", endpoint=twm_geofm_ablation_gate, methods=["POST"]),
596+
Route("/api/twm/states/{id}/geofm-downstream-experiment-report", endpoint=twm_geofm_downstream_experiment_report, methods=["POST"]),
576597
Route("/api/twm/states/{id}/causal-calibration-report", endpoint=twm_causal_calibration_report, methods=["POST"]),
577598
Route("/api/twm/scenarios", endpoint=twm_scenarios, methods=["GET", "POST"]),
578599
Route("/api/twm/scenarios/{id}/compare", endpoint=twm_scenario_compare, methods=["GET", "POST"]),

data_agent/fusion/twm_state_input.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ def validate_twm_state_input(payload: dict) -> dict:
154154
errors.append("object_role_registry must be a list")
155155
if not isinstance(payload.get("semantic_relation_registry"), list):
156156
errors.append("semantic_relation_registry must be a list")
157+
if payload.get("canonical_object_type_registry") is not None and not isinstance(payload.get("canonical_object_type_registry"), list):
158+
errors.append("canonical_object_type_registry must be a list")
157159

158160
relation_summary = payload.get("semantic_relation_summary") or {}
159161
relation_registry = payload.get("semantic_relation_registry") or []
@@ -165,9 +167,86 @@ def validate_twm_state_input(payload: dict) -> dict:
165167
components = payload.get("state_components") or {}
166168
if not isinstance(components, dict):
167169
errors.append("state_components must be an object")
170+
components = {}
171+
if isinstance(payload.get("object_role_registry"), list):
172+
errors.extend(_validate_role_type_closure(payload))
173+
if isinstance(relation_registry, list):
174+
errors.extend(_validate_component_reference_closure(payload, components, relation_registry))
168175
return {"valid": not errors, "errors": errors}
169176

170177

178+
def _validate_role_type_closure(payload: dict) -> list[str]:
179+
errors: list[str] = []
180+
role_registry = [row for row in payload.get("object_role_registry") or [] if isinstance(row, dict)]
181+
canonical_registry = [row for row in payload.get("canonical_object_type_registry") or [] if isinstance(row, dict)]
182+
canonical_types = {str(row.get("object_type")) for row in canonical_registry if row.get("object_type")}
183+
seen_roles: set[str] = set()
184+
for idx, row in enumerate(role_registry):
185+
role = str(row.get("role") or "")
186+
standard_role = str(row.get("standard_role") or "")
187+
object_type = str(row.get("object_type") or "")
188+
if not role:
189+
errors.append(f"object_role_registry[{idx}].role is required")
190+
elif role in seen_roles:
191+
errors.append(f"object_role_registry role is duplicated: {role}")
192+
seen_roles.add(role)
193+
if not standard_role:
194+
errors.append(f"object_role_registry[{idx}].standard_role is required")
195+
if not object_type:
196+
errors.append(f"object_role_registry[{idx}].object_type is required")
197+
elif canonical_types and object_type not in canonical_types:
198+
errors.append(f"object_role_registry[{idx}].object_type is not in canonical_object_type_registry: {object_type}")
199+
for idx, row in enumerate(canonical_registry):
200+
object_type = row.get("object_type")
201+
if not object_type:
202+
errors.append(f"canonical_object_type_registry[{idx}].object_type is required")
203+
return errors
204+
205+
206+
def _validate_component_reference_closure(payload: dict, components: dict, relation_registry: list[dict]) -> list[str]:
207+
errors: list[str] = []
208+
relation_rule_ids = {
209+
str(rule_id)
210+
for row in relation_registry
211+
if isinstance(row, dict)
212+
for rule_id in row.get("rule_ids") or []
213+
if rule_id
214+
}
215+
relation_objective_ids = {
216+
str(objective_id)
217+
for row in relation_registry
218+
if isinstance(row, dict)
219+
for objective_id in row.get("objective_ids") or []
220+
if objective_id
221+
}
222+
optimization_interface = payload.get("optimization_interface") or {}
223+
objective_bindings = optimization_interface.get("objective_bindings") or []
224+
bound_objective_ids = {
225+
str(row.get("objective_id"))
226+
for row in objective_bindings
227+
if isinstance(row, dict) and row.get("objective_id")
228+
}
229+
known_objective_ids = relation_objective_ids | bound_objective_ids
230+
231+
for component_name, component in components.items():
232+
if not isinstance(component, dict):
233+
errors.append(f"state_components.{component_name} must be an object")
234+
continue
235+
for rule_id in component.get("rule_ids") or []:
236+
if rule_id and str(rule_id) not in relation_rule_ids:
237+
errors.append(f"state_components.{component_name}.rule_ids references unknown semantic relation rule_id: {rule_id}")
238+
for objective_id in component.get("objective_ids") or []:
239+
if objective_id and str(objective_id) not in known_objective_ids:
240+
errors.append(f"state_components.{component_name}.objective_ids references unknown objective_id: {objective_id}")
241+
242+
hard_constraints = components.get("hard_constraints") or {}
243+
if isinstance(hard_constraints, dict):
244+
for objective_id in hard_constraints.get("objective_ids") or []:
245+
if objective_id and str(objective_id) not in bound_objective_ids:
246+
errors.append(f"state_components.hard_constraints.objective_ids is not bound in optimization_interface: {objective_id}")
247+
return errors
248+
249+
171250
def write_twm_state_input(payload: dict, out_path: str | Path) -> str:
172251
"""Write a TWM state-input artifact and return its path."""
173252
path = Path(out_path)

data_agent/territory_world_model/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
TwmDynamicsTrainingExample,
1818
TwmAuditReport,
1919
TwmEvidenceItem,
20+
TwmGeoFMDownstreamExperimentReport,
2021
TwmGeoFMGateReport,
2122
TwmGeoFMGateVariant,
2223
TwmLayerBinding,
@@ -51,6 +52,7 @@
5152
from .rule_dsl import normalize_rule_body, validate_rule_body
5253
from .rule_evaluator import RuleEvaluator, evaluate_rules
5354
from .service import TerritoryWorldModelService, get_territory_world_model_service, reset_territory_world_model_service
55+
from .spatial_causal_estimator import SpatialCausalEstimatorAdapter, estimate_spatial_treatment_effect
5456
from .state_builder import StateBuilder, build_state_from_bundle, load_state_source
5557

5658
__all__ = [
@@ -70,6 +72,7 @@
7072
"TwmDynamicsTrainingDataset",
7173
"TwmDynamicsTrainingExample",
7274
"TwmEvidenceItem",
75+
"TwmGeoFMDownstreamExperimentReport",
7376
"TwmGeoFMGateReport",
7477
"TwmGeoFMGateVariant",
7578
"TwmLayerBinding",
@@ -99,11 +102,13 @@
99102
"TwmWorldModelProfile",
100103
"RuleEvaluator",
101104
"StateBuilder",
105+
"SpatialCausalEstimatorAdapter",
102106
"TerritoryWorldModelService",
103107
"build_evidence_chain",
104108
"build_state_from_bundle",
105109
"evidence_checksum",
106110
"evaluate_rules",
111+
"estimate_spatial_treatment_effect",
107112
"get_territory_world_model_service",
108113
"get_twm_repository",
109114
"jsonable",

data_agent/territory_world_model/causal_calibration.py

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import math
44
from typing import Any
55

6+
from .spatial_causal_estimator import build_neighbor_edges, estimate_spatial_treatment_effect
67
from .utils import safe_float, truthy
78

89

@@ -30,7 +31,15 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
3031
aipw_effect, aipw_se, influence = _augmented_ipw_ate(usable)
3132
balance = _covariate_balance(usable)
3233
overlap = _overlap_diagnostics(usable, thresholds)
33-
spatial = _spatial_interference_diagnostics(usable, influence, thresholds)
34+
neighbor_edges = build_neighbor_edges(usable, thresholds)
35+
spatial = _spatial_interference_diagnostics(usable, influence, thresholds, neighbor_edges=neighbor_edges)
36+
spatial_estimator = estimate_spatial_treatment_effect(
37+
usable,
38+
thresholds=thresholds,
39+
neighbor_edges=neighbor_edges,
40+
observational_effect=aipw_effect,
41+
observational_standard_error=aipw_se,
42+
)
3443

3544
primary_name, primary_effect, primary_se = _primary_estimator(
3645
naive_effect=naive_effect,
@@ -45,6 +54,7 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
4554
control_count=len(control),
4655
thresholds=thresholds,
4756
overlap=overlap,
57+
spatial_estimator=spatial_estimator,
4858
)
4959
model_effects = [safe_float(row.get("model_effect"), None) for row in usable]
5060
model_effects = [float(item) for item in model_effects if item is not None]
@@ -85,6 +95,7 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
8595
"overlap": overlap,
8696
"balance": balance,
8797
"spatial": spatial,
98+
"spatial_estimator": spatial_estimator,
8899
}
89100

90101

@@ -249,7 +260,21 @@ def _primary_estimator(
249260
control_count: int,
250261
thresholds: dict[str, Any],
251262
overlap: dict[str, Any],
263+
spatial_estimator: dict[str, Any] | None = None,
252264
) -> tuple[str, float, float]:
265+
if (
266+
spatial_estimator
267+
and spatial_estimator.get("status") == "pass"
268+
and usable_count >= int(thresholds.get("min_records", 8))
269+
and treated_count >= int(thresholds.get("min_treated", 3))
270+
and control_count >= int(thresholds.get("min_control", 3))
271+
and overlap.get("status") == "pass"
272+
):
273+
return (
274+
"spatial_fixed_effect_neighbor_adapter",
275+
float(spatial_estimator.get("effect") or 0.0),
276+
float(spatial_estimator.get("standard_error") or 0.0),
277+
)
253278
if (
254279
usable_count >= int(thresholds.get("min_records", 8))
255280
and treated_count >= int(thresholds.get("min_treated", 3))
@@ -312,7 +337,13 @@ def _overlap_diagnostics(usable: list[dict[str, Any]], thresholds: dict[str, Any
312337
}
313338

314339

315-
def _spatial_interference_diagnostics(usable: list[dict[str, Any]], influence: list[float], thresholds: dict[str, Any]) -> dict[str, Any]:
340+
def _spatial_interference_diagnostics(
341+
usable: list[dict[str, Any]],
342+
influence: list[float],
343+
thresholds: dict[str, Any],
344+
*,
345+
neighbor_edges: list[tuple[int, int, float]] | None = None,
346+
) -> dict[str, Any]:
316347
spatial_rows = [row for row in usable if row.get("spatial")]
317348
if not spatial_rows:
318349
return {
@@ -323,7 +354,7 @@ def _spatial_interference_diagnostics(usable: list[dict[str, Any]], influence: l
323354
"note": "no spatial coordinates, cluster ids or neighbor links supplied",
324355
}
325356

326-
neighbor_edges = _neighbor_edges(usable, thresholds)
357+
neighbor_edges = list(neighbor_edges or [])
327358
cluster_summary = _spatial_cluster_summary(usable)
328359
exposure = _neighborhood_exposure(usable, neighbor_edges)
329360
moran = _moran_like_residual_correlation(usable, influence, neighbor_edges)
@@ -366,34 +397,18 @@ def _spatial_attributes(row: dict[str, Any]) -> dict[str, Any]:
366397
cluster = row.get("spatial_cluster") or row.get("cluster") or row.get("block_id") or row.get("township_id")
367398
if cluster is not None:
368399
spatial["cluster"] = str(cluster)
369-
neighbors = row.get("neighbors") or row.get("neighbor_unit_ids") or []
370-
if isinstance(neighbors, (list, tuple, set)) and neighbors:
371-
spatial["neighbors"] = [str(item) for item in neighbors]
400+
neighbors = _neighbor_ids(row.get("neighbors") or row.get("neighbor_unit_ids") or [])
401+
if neighbors:
402+
spatial["neighbors"] = neighbors
372403
return spatial
373404

374405

375-
def _neighbor_edges(usable: list[dict[str, Any]], thresholds: dict[str, Any]) -> list[tuple[int, int, float]]:
376-
index_by_id = {str(row.get("unit_id") or idx): idx for idx, row in enumerate(usable)}
377-
edges: dict[tuple[int, int], float] = {}
378-
for idx, row in enumerate(usable):
379-
spatial = dict(row.get("spatial") or {})
380-
for neighbor_id in spatial.get("neighbors") or []:
381-
other = index_by_id.get(str(neighbor_id))
382-
if other is None or other == idx:
383-
continue
384-
pair = tuple(sorted((idx, other)))
385-
edges[pair] = 1.0
386-
387-
distance_threshold = safe_float(thresholds.get("spatial_neighbor_distance"), None)
388-
coordinate_rows = [(idx, dict(row.get("spatial") or {})) for idx, row in enumerate(usable) if "x" in dict(row.get("spatial") or {}) and "y" in dict(row.get("spatial") or {})]
389-
if distance_threshold is not None and distance_threshold > 0 and len(coordinate_rows) > 1:
390-
for pos, (idx, left) in enumerate(coordinate_rows):
391-
for other, right in coordinate_rows[pos + 1 :]:
392-
distance = math.sqrt((float(left["x"]) - float(right["x"])) ** 2 + (float(left["y"]) - float(right["y"])) ** 2)
393-
if distance <= float(distance_threshold):
394-
pair = tuple(sorted((idx, other)))
395-
edges[pair] = 1.0 / max(distance, 1e-9)
396-
return [(left, right, weight) for (left, right), weight in sorted(edges.items())]
406+
def _neighbor_ids(value: Any) -> list[str]:
407+
if isinstance(value, (list, tuple, set)):
408+
return [str(item).strip() for item in value if str(item).strip()]
409+
if isinstance(value, str):
410+
return [item.strip() for item in value.replace(";", ",").split(",") if item.strip()]
411+
return []
397412

398413

399414
def _spatial_cluster_summary(usable: list[dict[str, Any]]) -> dict[str, Any]:

data_agent/territory_world_model/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ class TwmBeamPlanReport:
585585
schema: str = "territory_world_model.beam_plan_report.v1"
586586
scenario: str = "baseline"
587587
status: str = "review"
588+
ranking_policy: dict[str, Any] = field(default_factory=dict)
588589
candidates: list[dict[str, Any]] = field(default_factory=list)
589590
ranking: list[dict[str, Any]] = field(default_factory=list)
590591
selected: dict[str, Any] = field(default_factory=dict)
@@ -628,6 +629,23 @@ def to_dict(self) -> dict[str, Any]:
628629
return jsonable(self)
629630

630631

632+
@dataclass
633+
class TwmGeoFMDownstreamExperimentReport:
634+
state_version_id: str = ""
635+
project_id: str = ""
636+
schema: str = "territory_world_model.geofm_downstream_experiment_report.v1"
637+
status: str = "review"
638+
experiment: dict[str, Any] = field(default_factory=dict)
639+
variants: dict[str, Any] = field(default_factory=dict)
640+
evidence: dict[str, Any] = field(default_factory=dict)
641+
gate_report: dict[str, Any] = field(default_factory=dict)
642+
recommendations: list[str] = field(default_factory=list)
643+
created_at: str = field(default_factory=now_utc_iso)
644+
645+
def to_dict(self) -> dict[str, Any]:
646+
return jsonable(self)
647+
648+
631649
@dataclass
632650
class TwmCausalCalibrationReport:
633651
state_version_id: str = ""

0 commit comments

Comments
 (0)