Skip to content

Commit 4502c81

Browse files
Phase D: Conversations + Social Posts (#66)
* feat: Phase D - agent conversations and narrative prompts - Add conversation system for agent-agent and agent-NPC exchanges - Add --fidelity flag: low (no convs), medium (2 turns), high (3 turns) - Add talk_to action type to reasoning schema - Add relationship_weights to ScenarioSpec for conversation priority - Add conversations table to study DB with helper methods - Integrate conversation phase into engine timestep loop - Add available_contacts to reasoning prompts - Export conversation stats and conversations.json in results * feat: add social_posts table and helper methods to StudyDB * feat: record social posts from sharing agents during timestep execution * feat: add social feed to reasoning context and prompts - Add social_feed field to ReasoningContext - Build feed from recent posts beyond agent's direct network - Render 'What People Are Saying Online' section in prompts * feat: export social_posts.json in results * docs: add Phase D conversations and social posts to capabilities * docs: mark DayPhases as omitted in v2 architecture * fix: remove unnecessary async markers from fidelity tests * fix: remove unused pytest import
1 parent b41259f commit 4502c81

12 files changed

Lines changed: 2287 additions & 76 deletions

File tree

docs/capabilities.md

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,41 @@ After simulation runs, you have:
234234

235235
---
236236

237+
## Agent Conversations
238+
239+
Agents can talk to each other. When reasoning, an agent can choose to initiate a conversation with someone in their network.
240+
241+
**Talk-to actions**: During reasoning, agents can request to talk to someone - a coworker, neighbor, partner, or family member. The request includes who they want to talk to and what topic they want to discuss.
242+
243+
**Multi-turn exchanges**: Conversations are full back-and-forth dialogues. The initiator speaks, the target responds, they go back and forth for 2-3 turns depending on fidelity settings. Each turn is a complete LLM call with the speaker's persona and context.
244+
245+
**State updates from conversations**: Conversations change minds. After talking, both participants get updated sentiment, conviction, and potentially position. These conversation-driven state changes override provisional reasoning state - a compelling conversation can shift someone more than passive exposure.
246+
247+
**Partner and NPC conversations**: Agents can talk to their partner (whether another full agent or an NPC spouse) or household dependents. NPC conversations work the same way, but only the initiator's state updates - NPCs don't have persistent state.
248+
249+
**Conflict resolution**: When multiple agents want to talk to the same target, priority determines who wins. Higher relationship weight wins - a partner request beats a coworker request. Deferred requests can execute in later timesteps.
250+
251+
**Fidelity control**: The `--fidelity` flag controls conversation depth:
252+
- `low`: No conversations at all - just reasoning
253+
- `medium` (default): 2 turns (4 messages), top 1 conversation per agent
254+
- `high`: 3 turns (6 messages), up to 2 conversations per agent
255+
256+
---
257+
258+
## Social Feed and Public Discourse
259+
260+
Agents don't just see their direct network neighbors. They perceive broader public discourse.
261+
262+
**Public statements become posts**: When agents share (set `will_share=True`), their public statement gets recorded as a social post. These accumulate over timesteps, creating a timeline of what people are saying.
263+
264+
**Social feed beyond network**: In subsequent timesteps, agents see a "What People Are Saying Online" section in their prompts. This shows recent posts from the broader population - not just direct neighbors. It's like seeing trending takes on social media from strangers.
265+
266+
**Network vs. public**: Peer opinions come from direct network neighbors. The social feed comes from everyone else who's sharing. Agents experience both - personal network influence and ambient public discourse.
267+
268+
**Configurable lookback**: The feed pulls from recent timesteps (default: 3 timesteps back, 5 posts shown). This models the recency bias of social media - you see what's trending now, not old takes.
269+
270+
---
271+
237272
## Scenarios You Can Run Today
238273

239274
To make it concrete, here are scenarios that work right now with no additional development:
@@ -249,12 +284,13 @@ To make it concrete, here are scenarios that work right now with no additional d
249284
- Professional networks processing industry disruption news
250285
- Religious communities responding to doctrinal changes
251286
- Parent networks reacting to school policy updates
287+
- Couples having conversations that shift their positions
288+
- Workplace discussions that change minds
289+
- Social media dynamics where public discourse influences individuals
252290
- Any population, any country, any event, any outcome structure
253291

254292
The constraints are:
255-
- No agent-to-agent conversations (yet)
256-
- No agents creating public social posts (yet)
257-
- No runtime fidelity/cost tradeoffs beyond merged pass (yet)
293+
- No runtime fidelity/cost tradeoffs beyond merged pass and fidelity levels (yet)
258294
- No validation against historical ground truth (yet)
259295

260-
Those are Phases D, E, F, and G. What's here now is Phases A, B, and C - the core simulation engine with households, networks, timelines, and reasoning.
296+
Those are Phases E and F. What's here now is Phases A through D - the core simulation engine with households, networks, timelines, conversations, and social posts.

docs/simulation-v2-architecture.md

Lines changed: 4 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -339,55 +339,9 @@ Each timeline event:
339339

340340
For scenarios with no evolution (Netflix password sharing), the timeline is just the single t=0 event. No extra configuration needed.
341341

342-
#### Day Phases (New, Optional)
342+
#### ~~Day Phases~~ (OMITTED)
343343

344-
Templates that structure the agent's timestep experience into life contexts. Optional — if omitted, the engine uses an improved flat prompt (still much better than current).
345-
346-
```yaml
347-
day_phases:
348-
defaults:
349-
- phase: morning
350-
condition: "true"
351-
template: "I wake up and check my phone."
352-
slots: [social_media_exposures, aggregate_mood]
353-
- phase: work
354-
condition: "employment_status in ['employed full-time', 'employed part-time', 'self-employed']"
355-
template: "I head to work at my {occupation_sector} job."
356-
slots: [workplace_exposures, coworker_opinions]
357-
- phase: school
358-
condition: "school_enrollment != 'not enrolled'"
359-
template: "I go to class."
360-
slots: [peer_opinions]
361-
- phase: evening
362-
condition: "true"
363-
template: "I'm home for the evening."
364-
slots: [family_context, partner_opinion, network_opinions, memory_trace]
365-
366-
# Override for weekly timesteps
367-
weekly:
368-
- phase: this_week_at_work
369-
condition: "employment_status in ['employed full-time', 'employed part-time', 'self-employed']"
370-
template: "This week at work..."
371-
slots: [workplace_exposures, coworker_opinions]
372-
- phase: this_week_at_home
373-
condition: "true"
374-
template: "At home this week..."
375-
slots: [family_context, partner_opinion, aggregate_mood]
376-
- phase: this_week_online
377-
condition: "social_media_usage in ['heavy/multiple times daily', 'moderate/daily']"
378-
template: "Online this week..."
379-
slots: [social_media_exposures, public_discourse]
380-
```
381-
382-
Phase selection adapts to `timestep_unit`:
383-
- Daily → morning/work/evening
384-
- Weekly → this_week_at_work/home/online
385-
- Monthly → this_month overview
386-
- Hourly → single phase per timestep
387-
388-
Agent attributes determine which phases render. A retiree skips `work`. A student gets `school` instead. Night shift workers get a different `work` time slot. This is evaluated at prompt build time using the condition expressions.
389-
390-
If no `day_phases` defined in scenario YAML → engine uses sensible defaults based on timestep_unit.
344+
**Decision:** Day phase templates are not implemented. The improved flat prompt structure (first-person voice, temporal awareness, named peers, local mood, social feed) provides sufficient narrative context without explicit morning/work/evening phase segmentation. The complexity of condition-based phase selection doesn't justify the marginal improvement over the current prompt design.
391345

392346
#### Outcome Tracks (New)
393347

@@ -492,20 +446,7 @@ class TimelineEvent(BaseModel):
492446
exposure_rules: list[ExposureRule] | None = None # If None, reuse seed_exposure rules
493447
description: str | None = None # Human-readable context for this development
494448
495-
class DayPhase(BaseModel):
496-
"""A single phase in a day template."""
497-
phase: str
498-
condition: str
499-
template: str
500-
slots: list[str]
501-
502-
class DayPhaseConfig(BaseModel):
503-
"""Day phase templates, optionally keyed by timestep unit."""
504-
defaults: list[DayPhase] | None = None
505-
hourly: list[DayPhase] | None = None
506-
daily: list[DayPhase] | None = None
507-
weekly: list[DayPhase] | None = None
508-
monthly: list[DayPhase] | None = None
449+
# DayPhase and DayPhaseConfig — OMITTED (see "Day Phases" section above)
509450
510451
class ChannelVariant(BaseModel):
511452
when: str
@@ -524,7 +465,7 @@ class ScenarioSpec(BaseModel):
524465
spread: SpreadConfig
525466
outcomes: OutcomeConfig
526467
simulation: SimulationConfig
527-
day_phases: DayPhaseConfig | None = None # NEW
468+
# day_phases — OMITTED
528469
channel_experience: dict[str, ChannelExperience] | None = None # NEW
529470
relationship_weights: dict[str, float] | None = None # NEW
530471
```

extropy/cli/commands/simulate.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ def simulate_command(
191191
"--merged-pass",
192192
help="Use single merged reasoning pass instead of two-pass (experimental)",
193193
),
194+
fidelity: str = typer.Option(
195+
"medium",
196+
"--fidelity",
197+
"-f",
198+
help="Fidelity level: low (no conversations), medium (2 turns), high (3 turns)",
199+
),
194200
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress output"),
195201
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed logs"),
196202
debug: bool = typer.Option(
@@ -322,6 +328,7 @@ def on_progress(timestep: int, max_timesteps: int, status: str):
322328
db_write_batch_size=db_write_batch_size,
323329
resource_governor=governor,
324330
merged_pass=merged_pass,
331+
fidelity=fidelity,
325332
)
326333
simulation_error = None
327334
except Exception as e:
@@ -359,6 +366,7 @@ def do_simulation():
359366
db_write_batch_size=db_write_batch_size,
360367
resource_governor=governor,
361368
merged_pass=merged_pass,
369+
fidelity=fidelity,
362370
)
363371
except Exception as e:
364372
simulation_error = e

extropy/core/models/scenario.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,24 @@ class SimulationConfig(BaseModel):
281281
)
282282

283283

284+
# =============================================================================
285+
# Conversation Relationship Weights
286+
# =============================================================================
287+
288+
# Default relationship weights used when scenario doesn't specify custom weights
289+
DEFAULT_RELATIONSHIP_WEIGHTS: dict[str, float] = {
290+
"partner": 1.0,
291+
"household": 0.9,
292+
"close_friend": 0.7,
293+
"coworker": 0.6,
294+
"neighbor": 0.4,
295+
"congregation": 0.4,
296+
"school_parent": 0.35,
297+
"acquaintance": 0.2,
298+
"online_contact": 0.15,
299+
}
300+
301+
284302
# =============================================================================
285303
# Complete Scenario Spec
286304
# =============================================================================
@@ -318,6 +336,10 @@ class ScenarioSpec(BaseModel):
318336
default=None,
319337
description="Optional background context injected into reasoning prompts",
320338
)
339+
relationship_weights: dict[str, float] | None = Field(
340+
default=None,
341+
description="Scenario-specific edge weights for conversation priority and peer ordering",
342+
)
321343

322344
def to_yaml(self, path: Path | str) -> None:
323345
"""Save scenario spec to YAML file."""

extropy/core/models/simulation.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from datetime import datetime
1919
from enum import Enum
20-
from typing import Any
20+
from typing import Any, Literal
2121

2222
from pydantic import BaseModel, Field
2323

@@ -331,6 +331,15 @@ class ReasoningContext(BaseModel):
331331
default=None,
332332
description="Agent's conformity attribute (0-1)",
333333
)
334+
# Phase D additions
335+
available_contacts: list[dict[str, Any]] = Field(
336+
default_factory=list,
337+
description="People the agent can talk to (name, relationship, observable state)",
338+
)
339+
social_feed: list[dict[str, Any]] = Field(
340+
default_factory=list,
341+
description="Recent public statements from the broader population (beyond direct network)",
342+
)
334343

335344

336345
# =============================================================================
@@ -366,6 +375,10 @@ class ReasoningResponse(BaseModel):
366375
outcomes: dict[str, Any] = Field(
367376
default_factory=dict, description="All structured outcomes (from Pass 2)"
368377
)
378+
actions: list[dict[str, Any]] = Field(
379+
default_factory=list,
380+
description="Actions the agent intends to take (talk_to, etc.)",
381+
)
369382

370383
# Token usage tracking (populated by two-pass reasoning)
371384
pass1_input_tokens: int = Field(default=0, description="Pass 1 input tokens")
@@ -411,6 +424,10 @@ class SimulationRunConfig(BaseModel):
411424
default=False,
412425
description="Use single merged pass instead of two-pass reasoning (experimental)",
413426
)
427+
fidelity: Literal["low", "medium", "high"] = Field(
428+
default="medium",
429+
description="Fidelity level controlling conversation depth and prompt richness",
430+
)
414431

415432
# Backward compat aliases
416433
@property

extropy/simulation/aggregation.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Aggregate computation for simulation results.
22
33
Computes population-level statistics including per-timestep summaries,
4-
segment breakdowns, and outcome distributions.
4+
segment breakdowns, outcome distributions, and conversation statistics.
55
"""
66

77
from collections import defaultdict
@@ -13,6 +13,7 @@
1313
PopulationSpec,
1414
TimestepSummary,
1515
)
16+
from ..storage import StudyDB
1617
from .state import StateManager
1718

1819

@@ -296,3 +297,49 @@ def compute_timeline_aggregates(
296297
)
297298

298299
return timeline
300+
301+
302+
def compute_conversation_stats(
303+
study_db: StudyDB,
304+
run_id: str,
305+
max_timesteps: int,
306+
) -> dict[str, Any]:
307+
"""Compute conversation statistics for a simulation run.
308+
309+
Args:
310+
study_db: Study database connection
311+
run_id: Simulation run ID
312+
max_timesteps: Maximum timestep for iteration
313+
314+
Returns:
315+
Dict with conversation statistics
316+
"""
317+
total_conversations = 0
318+
conversations_by_timestep: dict[int, int] = {}
319+
state_changes_from_conversations = 0
320+
total_messages = 0
321+
322+
for timestep in range(max_timesteps):
323+
convs = study_db.get_conversations_for_timestep(run_id, timestep)
324+
count = len(convs)
325+
total_conversations += count
326+
if count > 0:
327+
conversations_by_timestep[timestep] = count
328+
329+
for conv in convs:
330+
messages = conv.get("messages", [])
331+
total_messages += len(messages)
332+
if conv.get("initiator_state_change"):
333+
state_changes_from_conversations += 1
334+
if conv.get("target_state_change"):
335+
state_changes_from_conversations += 1
336+
337+
avg_turns = total_messages / total_conversations if total_conversations > 0 else 0
338+
339+
return {
340+
"total_conversations": total_conversations,
341+
"conversations_by_timestep": conversations_by_timestep,
342+
"state_changes_from_conversations": state_changes_from_conversations,
343+
"total_messages": total_messages,
344+
"avg_turns": round(avg_turns, 2),
345+
}

0 commit comments

Comments
 (0)