Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions extropy/cli/commands/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ def network_command(
"auto_save_generated_config": auto_save_generated_config,
"quarantine_suffix": quarantine_suffix,
}
config = (
config.model_copy(update=base_updates).apply_quality_profile_defaults(force=True)
config = config.model_copy(update=base_updates).apply_quality_profile_defaults(
force=True
)

advanced_updates = {}
Expand Down Expand Up @@ -541,7 +541,9 @@ def do_generation():

quality_meta = result.meta.get("quality", {})
accepted = bool(quality_meta.get("accepted", True))
strict_failed = config.topology_gate == "strict" and not accepted and len(agents) >= 50
strict_failed = (
config.topology_gate == "strict" and not accepted and len(agents) >= 50
)

# Save canonical output to study DB (or quarantine on strict failure)
console.print()
Expand Down Expand Up @@ -583,15 +585,15 @@ def do_generation():
console.print(
"[yellow]![/yellow] Topology gate strict failed. Saved quarantined artifact; canonical network not overwritten."
)
console.print(
f"[yellow]![/yellow] Quarantined network_id={target_network_id}"
)
console.print(f"[yellow]![/yellow] Quarantined network_id={target_network_id}")
console.print(
f"[red]✗[/red] Failed gates with best metrics: {quality_meta.get('best_metrics', {})}"
)
if gate_deltas:
console.print(f"[dim]Gate deltas: {gate_deltas}[/dim]")
console.print(f"[dim]inspect via: extropy inspect network-status --study-db {study_db} --network-run-id {network_run_id}[/dim]")
console.print(
f"[dim]inspect via: extropy inspect network-status --study-db {study_db} --network-run-id {network_run_id}[/dim]"
)
raise typer.Exit(1)
if strict_failed and not config.allow_quarantine:
console.print(
Expand Down
39 changes: 39 additions & 0 deletions extropy/cli/commands/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,33 @@ def on_progress(current: int, total: int):
)
out.blank()

# Household report (if applicable)
households = getattr(result, "_households", [])
if households and report and not get_json_mode():
out.header("HOUSEHOLD REPORT")
type_counts: dict[str, int] = {}
for hh in households:
ht = hh["household_type"]
type_counts[ht] = type_counts.get(ht, 0) + 1
hh_rows = [[htype, str(cnt)] for htype, cnt in sorted(type_counts.items())]
out.table(
"Household Types",
["Type", "Count"],
hh_rows,
styles=["cyan", None],
)
out.text(
f" Total households: {len(households)}, Total agents: {len(result.agents)}"
)
out.blank()

if households and get_json_mode():
out.set_data("household_count", len(households))
out.set_data(
"household_type_distribution",
result.meta.get("household_type_distribution", {}),
)

# Save to canonical DB
out.blank()
if not get_json_mode():
Expand All @@ -301,6 +328,12 @@ def on_progress(current: int, total: int):
meta=result.meta,
seed=result.meta.get("seed"),
)
if households:
db.save_households(
population_id=population_id,
sample_run_id=sample_run_id,
households=households,
)
else:
with open_study_db(study_db) as db:
db.save_population_spec(
Expand All @@ -314,6 +347,12 @@ def on_progress(current: int, total: int):
meta=result.meta,
seed=result.meta.get("seed"),
)
if households:
db.save_households(
population_id=population_id,
sample_run_id=sample_run_id,
households=households,
)

elapsed = time.time() - start_time

Expand Down
8 changes: 8 additions & 0 deletions extropy/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
PopulationSpec,
# Pipeline types
SufficiencyResult,
# Household models
HouseholdType,
Dependent,
STANDARD_PERSONALITY_ATTRIBUTES,
)

# Validation models (shared across population and scenario)
Expand Down Expand Up @@ -139,6 +143,10 @@
"PopulationSpec",
# Population - Pipeline types
"SufficiencyResult",
# Population - Household models
"HouseholdType",
"Dependent",
"STANDARD_PERSONALITY_ATTRIBUTES",
# Scenario - Event
"EventType",
"Event",
Expand Down
9 changes: 8 additions & 1 deletion extropy/core/models/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ class Edge(BaseModel):
edge_type: str
bidirectional: bool = True
influence_weight: dict[str, float] = Field(default_factory=dict)
structural: bool = False
context: str | None = None # "household", "workplace", "neighborhood", etc.

def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
d: dict[str, Any] = {
"source": self.source,
"target": self.target,
"weight": round(self.weight, 4),
Expand All @@ -46,6 +48,11 @@ def to_dict(self) -> dict[str, Any]:
),
},
}
if self.structural:
d["structural"] = True
if self.context:
d["context"] = self.context
return d


class NodeMetrics(BaseModel):
Expand Down
41 changes: 41 additions & 0 deletions extropy/core/models/population.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,50 @@

from collections import defaultdict
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Literal

import yaml
from pydantic import BaseModel, Field


# =============================================================================
# Household Models
# =============================================================================


class HouseholdType(str, Enum):
SINGLE = "single"
COUPLE = "couple"
SINGLE_PARENT = "single_parent"
COUPLE_WITH_KIDS = "couple_with_kids"
MULTI_GENERATIONAL = "multi_generational"


class Dependent(BaseModel):
"""NPC dependent (child, elderly parent)."""

name: str
age: int
gender: str
relationship: str # "son", "daughter", "mother", etc.
school_status: str | None = None # "home", "elementary", "middle_school", etc.


# Standard personality attributes that spec builders should include.
# `conformity` (float, 0-1, correlated with agreeableness) is consumed by
# Phase C for threshold behavior in simulation.
STANDARD_PERSONALITY_ATTRIBUTES = [
"neuroticism",
"extraversion",
"openness",
"conscientiousness",
"agreeableness",
"conformity",
]


# =============================================================================
# Grounding Information
# =============================================================================
Expand Down Expand Up @@ -247,6 +284,10 @@ class AttributeSpec(BaseModel):
"universal", "population_specific", "context_specific", "personality"
] = Field(description="Category of attribute")
description: str = Field(description="What this attribute represents")
scope: Literal["individual", "household"] = Field(
default="individual",
description="Whether this attribute is sampled per-individual or shared across a household",
)
sampling: SamplingConfig
grounding: GroundingInfo
constraints: list[Constraint] = Field(default_factory=list)
Expand Down
7 changes: 7 additions & 0 deletions extropy/population/network/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ class NetworkConfig(BaseModel):
target_clustering_tolerance: float = 0.08
bridge_budget_fraction: float = 0.08
swap_passes: int = 3
degree_distribution_target: Literal["uniform", "power_law"] | None = None
power_law_exponent: float = 2.5 # only used when target is power_law
identity_clustering_attributes: list[str] = Field(
default_factory=list,
description="Attributes for in-group edge density boost (e.g., political_orientation, religious_affiliation)",
)
identity_clustering_boost: float = 1.5 # multiplier on intra-group edge probability
auto_save_generated_config: bool = True
allow_quarantine: bool = True
quarantine_suffix: str = "rejected"
Expand Down
Loading
Loading