← Back to the app
Field guide · SocialNetwork3

What each experiment is, what its dials do, and how to read the numbers

A reference for the fourteen simulations behind the Behavioral Science Research Collective — every one traced back to its psychology or economics source study, its actual configurable parameters, and what its output metrics do and don't tell you.

14 experiments 6 domains ~18,400 words compiled from source, not documentation

Shared building blocks

Almost every experiment below reuses the same handful of underlying mechanisms. Knowing these once means each section can get straight to what's actually distinctive about it.

Concordia & the Game Master
The whole app is built on Google DeepMind's Concordia framework: LLM-driven agent "entities" act in turns, and a Game Master referees what's observed, resolves each action, and decides whose turn is next. Some Game Masters are hand-written and deterministic (Asch, Ultimatum); others are Concordia's own generic prefabs (the forum-based experiments).
Persona & PsychProfile
Every simulated participant is built from a sampled PsychProfile: a Big Five personality vector plus cognitive traits (including a conformity score used across several experiments), optionally layered with a demographic backstory.
Population: indian / western / ablated
The recurring independent variable. indian grounds the backstory in real Census 2011 India strata; western swaps in a lightweight U.S.-marginals sampler as a contrast; ablated strips demographic grounding entirely, leaving personality traits only — the "no cultural framing" control the other two are measured against.
Backend: fake / ollama
The single most important switch on every launch form. fake is a free, deterministic offline stub — useful only for confirming the plumbing works, never for a real reading. ollama (usually routed live through OpenRouter per each project's .env) is the one that produces genuine model behavior worth interpreting.
Seeds & reproducibility
A seed fixes who gets sampled (personas, demographics, sometimes stimulus ordering) — it does not fix what a live model actually says. Same seed, same population; not necessarily the same transcript.
Run controls
Concordia's StepController gives every live run play / pause / step / stop controls, and most forms offer a "start paused" checkbox so you can inspect a sampled population before spending any LLM calls on it.
Single runs vs. aggregates
Every run's log persists to a local SQLite database. A single run is one noisy sample — most experiments default to tiny sample sizes (4–10) to keep costs down. Nearly every experiment exposes a cross-run aggregate view that pools every run ever recorded for a condition; that pooled number, not any one run, is the one worth trusting.
Parse failures
Several experiments deterministically parse a number, letter, or label out of free-text model output (an offer amount, a chosen option, a classification). Records that failed to parse are flagged (*_parse_failed, or classified "unclear") and usually excluded from headline rates — check that flag before trusting a row, and check how many rows got excluded before trusting a rate.
Psychology

Asch Conformity

Does the model cave to a unanimous wrong answer?

What it is

This experiment is a text-based reconstruction of Solomon Asch's classic 1951 conformity study, in which a naive subject is asked to judge a simple, objectively-answerable perceptual question after hearing a group of confederates answer first. In the original study, subjects viewed a "standard" line and three comparison lines and had to say which comparison line matched the standard in length; the twist was that the other people in the room were actors instructed to unanimously give a wrong answer on certain "critical" trials, to see whether the subject would go along with the group rather than trust their own eyes. Asch found that a substantial minority of subjects conformed to the incorrect majority at least some of the time, even though the correct answer was visually unambiguous.

This webapp reimplements that paradigm textually rather than visually: instead of showing lines, socialnet2/asch/trials.py generates a "standard" numeric length (e.g., "reference line X measures 7.2 cm") and three labeled comparison values (A/B/C), one of which exactly matches the standard. The other two are generated with either a large, unambiguous gap (normal trials) or a small, barely-distinguishable gap (the ambiguity condition) from the standard, so that difficulty is knowable and controllable. Which letter holds the correct answer is re-randomized every trial so a model can't learn "the answer is always B."

A run consists of one designated Subject entity and zero or more Confederate entities, all coordinated by a bespoke, fully deterministic Game Master (AschExperimentGameMaster, implemented via the TrialController component in asch/game_master.py). Critically, no LLM call is ever made by the Game Master itself — turn order, trial content, and even the observation text each entity sees are all pre-computed data. The confederates are also not reasoning agents: they are ScriptedActComponents that simply read off a pre-generated list of answers, one per trial, with zero memory or perception. This is intentional — a confederate whose answer could be influenced by anything it "observes" would turn the experiment's control group into a second free variable, defeating the point.

Within one trial, entities answer in a fixed speaking order (sequence): all confederates but the last, then the Subject, then one final confederate — deliberately mirroring Asch's practice of having the naive subject answer "second to last," not dead last, so they hear most but not all of the group's answers before speaking themselves. Before the Subject answers, they're shown a running tally of what's been said so far in the trial (e.g., "Everyone who has answered so far gave the same answer," or "Not everyone gave the same answer — at least one of them disagreed with the rest"), which is the social pressure manipulation. After every entity in the sequence has answered once, that's one complete trial, and the Game Master moves to the next trial in the pre-generated trial_script. A full run consists of 18 trials by default (DEFAULT_N_TRIALS = 18), of which 12 are "critical" (the confederates unanimously state a wrong answer) and 6 are "neutral" (confederates answer correctly, to avoid tipping off the subject and to measure baseline task accuracy). The very first trial is forced to be neutral, matching Asch's own practice of opening with an uncontroversial trial. The Subject itself is not scripted — it is a full reasoning agent conditioned by a PsychProfile (a Big Five personality vector plus cognitive traits including a conformity trait, and optionally a Census-grounded demographic backstory), so its answer on each trial is a genuine model inference, not a lookup.

Parameters

These are the fields on the Asch launch form (webapp/static/asch.html) plus the underlying orchestration parameters they map to:

  • Condition (f-condition, dropdown, required) — which experimental manipulation to run. Populated dynamically from trials.conditions_by_name(), which includes: solo_control (0 confederates — the subject answers entirely alone; used to measure baseline accuracy uncontaminated by social pressure), ally (8 confederates, but the first confederate in speaking order always answers correctly while the rest are unanimously wrong — Asch's "one ally breaks unanimity" manipulation, which historically collapses conformity dramatically), ambiguity (8 confederates, unanimously wrong on critical trials, but the comparison-line gaps are shrunk to _AMBIGUOUS_GAP_RANGE (0.3–0.6) instead of _EASY_GAP_RANGE (1.5–3.0), making the task itself genuinely harder — this tests whether conformity increases when the "correct" answer is less obvious to begin with), and a group-size sweep: group_size_1, group_size_2, group_size_3, group_size_5, group_size_10, group_size_15 (Asch's own American group sizes) plus an Indian group-size sweep group_size_30, group_size_45 (10/15 are shared with the American sweep; 30/45 are added to match a same-country benchmark: Sahla Sherin K.'s 2025 WhatsApp-based Indian replication, letting you compare conformity curves across a same-culture, larger-N design). Each of these varies only n_confederates, holding everything else constant, to trace out how conformity scales with unanimous-group size.
  • Backend (f-backend, dropdown) — fake (an offline, deterministic stub language model — no external API calls, useful for testing the plumbing or verifying the UI without incurring LLM cost/latency; note the subject's answers under fake are not meaningful psychological data) or ollama (labeled "live (OpenRouter, via .env)" — routes to a real hosted LLM configured via the project's .env/settings.ollama_model, which is the mode that produces genuine model behavior).
  • Seed (f-seed, number, default 1) — the RNG seed. This single seed drives both (a) which PsychProfile gets sampled for the Subject (personality, cognitive traits including their individual conformity disposition, and demographic backstory) and (b) the trial script (stimulus values, which trials are critical vs. neutral, which letter holds the correct answer). Because both derive from the same seed by default, changing the seed changes both who the subject is and what they're asked — the backend orchestration function does expose an independent trial_seed to decouple these for more careful sweeps, but the webapp's single Seed field does not currently expose that split.
  • Ablate (f-ablate, checkbox, unchecked by default) — when checked, this drops the ConformityPressure component from the Subject entirely (passed through as enable_conformity_component=False). Normally, before answering, the subject is explicitly told (in a way calibrated to their sampled conformity trait level — "very low" through "very high") that "giving a different answer from a unanimous group carries real social weight," without ever instructing them to actually agree or disagree (to avoid confounding instruction-following with genuine conformity). Ablating this removes that framing sentence entirely, so the subject only sees the bare running tally of others' answers with no explicit narration of social stakes — useful as a component-level ablation study to isolate how much of any observed conformity is driven by this explicit framing versus the implicit pressure of merely observing a unanimous group.
  • Start paused (f-start-paused, checkbox, checked by default) — controls whether the run begins in a paused state (requiring you to click Play) or starts executing immediately. Purely a UX/control convenience via Concordia's StepController; has no effect on the science.

Two parameters exist in the backend (build_condition_config's n_trials and n_critical) but are not exposed in the current UI — they default to 18 total trials / 12 critical and cannot currently be changed from the web form.

Interpreting results

Each individual trial produces a TrialRecord with: condition, trial_index, trial_type ("critical" or "neutral"), group_size (number of confederates), correct_option (the objectively correct letter), majority_option (the letter most confederates stated — None on solo_control trials with zero confederates), subject_option (what the Subject actually answered), and conformed (boolean).

The conforming definition (scoring.score_trial) is deliberately strict: a subject only "conforms" on a trial if (1) the confederates had an actual wrong-answer majority, AND (2) the subject's answer matches that wrong majority exactly. Merely getting a trial wrong without matching the group's specific wrong answer does not count as conformity — this distinguishes genuine social conformity from ordinary perceptual error or random mistakes.

The headline metric is conformity_rate (scoring.conformity_rate): the fraction of critical trials (only those with an actual wrong majority — neutral trials are excluded by definition, matching how Asch himself always reported results) on which the subject conformed. A rate of 0.0 means the subject never went along with a unanimously wrong group; historically, Asch's original human studies found roughly 32% average conformity on critical trials, with about 75% of subjects conforming at least once and about 25% never conforming at all — useful reference points, though this is a different population (LLM subjects) and task modality (text, not visual lines), so don't expect an exact match.

A second, equally important metric is accuracy (scoring.accuracy): the fraction of all trials where the subject picked the objectively correct answer, independent of what confederates said. This exists specifically to validate the experimental design's precondition — run under solo_control (zero confederates), accuracy needs to be close to 1.0. If it isn't, that means the underlying model can't reliably do the line-comparison task at all, and a low conformity_rate under other conditions would be uninterpretable (you can't tell "resisted social pressure" apart from "couldn't do the task regardless of pressure").

When browsing history, the webapp also surfaces a cross-run aggregate_conformity view (asch/instrumentation.py) that pools every trial ever logged for a given (condition, group_size) pair across every run and seed, reporting n_runs, n_critical_trials, n_conformed, and the pooled conformity_rate. This cross-run view is the actual point of the experiment for the group-size sweep: a single run's conformity_rate is one noisy sample (with only 12 critical trials per run, a single flipped answer swings the rate by ~8 percentage points), so meaningful group-size curves require running each condition across multiple seeds and reading the aggregate, not any one run in isolation. Watch out for small-N noise especially at the tails of the group-size sweep, and remember that under the fake backend the subject's "reasoning" is a deterministic stub, so any conformity_rate you see there reflects the stub's fixed behavior, not real model psychology — only ollama runs are meaningful for interpreting actual conformity behavior.

Psychology

Collectivism

Self-reported vs. revealed cultural orientation — the INDCOL questionnaire and the dilemma task

The Collectivism experiment is really two related but structurally different sub-experiments sharing one page and one underlying PsychProfile/demographic-grounding system: the INDCOL Questionnaire (a validated self-report personality scale, answered directly) and the Dilemma Task (a set of behavioral scenarios where the "answer" is inferred from choices, not self-report). Both exist to validate the same underlying claim — that grounding an LLM agent's persona in real demographic data (Indian Census 2011 strata) versus a Western-marginal baseline versus no cultural backstory at all ("ablated") produces measurably different cultural-orientation behavior, in the direction the psychology literature predicts.

Sub-mode 1: INDCOL Questionnaire

What it is

This implements the Triandis & Gelfand (1998) 16-item Horizontal/Vertical Individualism-Collectivism scale (the "Culture Orientation Scale"), transcribed verbatim from the Fetzer Institute's public research archive. It is a real, validated psychometric instrument, not a bespoke stimulus like the Asch lines. The scale decomposes "individualism vs. collectivism" into four independent four-item subscales, because the literature treats these as orthogonal, not a single axis: Horizontal Individualism (HI — an autonomous self that nonetheless believes in equality, e.g. "I'd rather depend on myself than others"), Vertical Individualism (VI — an autonomous self that accepts inequality and competition, e.g. "Winning is everything"), Horizontal Collectivism (HC — a collective self that sees group members as equals, e.g. "I feel good when I cooperate with others"), and Vertical Collectivism (VC — a collective self that accepts in-group hierarchy and prioritizes family/group duty, e.g. "It is my duty to take care of my family, even when I have to sacrifice what I want"). Each of the 16 statements is answered on the source's original 9-point scale, from 1 ("never or definitely no") to 9 ("always or definitely yes"), and the 16 items interleave the four subscales in a fixed (not per-run randomized) order — the source instructs that items be mixed rather than grouped by subscale, and a fixed order keeps results reproducible run over run while still satisfying that instruction.

Mechanically, this runs on a completely different Concordia engine than Asch or the Dilemma task: Concordia's QuestionnaireSimulation with a ParallelQuestionnaireEngine, driven by the interviewer.GameMaster prefab. Because a questionnaire item has no turn-order-sensitive social dynamics (unlike Asch, where what you hear before answering matters enormously), every respondent answers every question concurrently rather than in a scripted sequence — there is no "Play/Pause/Step" control surface for this sub-mode, unlike Asch, precisely because there's no meaningful mid-run step to pause at. Each Respondent entity is a minimal persona-only agent: it has a PsychProfile (Big Five + cognitive traits + demographic backstory) and no memory, perception, or situational awareness components at all, because each questionnaire item is fully self-contained — nothing earlier in the session should influence a later answer, unlike the accumulating social pressure in Asch.

A typical run samples n_per_population respondents from each selected population, assembles them all as entities alongside one shared interviewer Game Master, and fires all 16 questions at all respondents in parallel, then aggregates each respondent's answers into four subscale sums.

Parameters

From webapp/static/collectivism.html's questionnaire panel and questionnaire_orchestration.build_questionnaire_config:

  • Backend (q-backend) — same fake/ollama choice as Asch; fake is for testing plumbing only, ollama for real results.
  • Populations (q-populations, checkboxes) — which of indian, western, ablated to include, each independently toggleable (all three checked by default). indian grounds each respondent's demographic backstory in Census 2011-derived Indian population strata (demographics.sample_demographic_profile); western uses a lightweight U.S.-marginals-based demographic sampler instead (demographics_western); ablated skips demographic grounding entirely (with_demographics=False) — the respondent gets a Big Five/cognitive-trait profile but no cultural backstory narrative at all, serving as the "no grounding" control the other two are compared against. Note: the personality/cognitive trait scores themselves (openness, conformity, etc., sampled from Beta(2,2)) are population-independent in all three cases — only the demographic backstory text differs. A fourth backend capability, four finer-grained Indian urbanization-gradient groups (delhi_urban, chandigarh_urban, haryana_urban, haryana_rural, from Jha & Singh 2011) exists in collectivism/urbanization.py but is not exposed in the current web UI — only reachable via direct orchestration calls or a CLI script.
  • Respondents per population (q-n, number, default 3, range 1–20) — how many independently-sampled respondents to generate for each checked population. Total entities in the run = (number of checked populations) × (this value). Higher values reduce per-run sampling noise in the subscale means at the cost of more LLM calls.
  • Seed (q-seed, number, default 1) — top-level RNG seed; each respondent's own profile-sampling seed is then drawn from this master seed's random stream, so the whole batch is reproducible from one number but every respondent still gets an independently-varied profile.

Interpreting results

For each respondent, the questionnaire component aggregates answers into four subscale sums — horizontal_individualism, vertical_individualism, horizontal_collectivism, vertical_collectivism — each computed by summing that subscale's four item scores on the source's true 1–9 scale (not averaging), so each subscale sum falls in the range [4, 36], directly comparable to the published norms from the original Triandis & Gelfand paper (this is a deliberate scoring choice — summing rather than averaging matches "each dimension's items are summed up separately" from the source methodology). A higher HC or VC score means the respondent's answers leaned more collectivist on that dimension; a higher HI or VI score means more individualist. Because HI/VI and HC/VC are separate axes, a respondent (or population) can score high on both individualism and collectivism subscales simultaneously (e.g., strongly endorsing both self-reliance and family duty) — don't collapse these into one "individualism minus collectivism" number without deciding that's actually what you want to measure.

Each respondent's row also carries their sampled conformity_trait (0–1, from their PsychProfile) alongside the four subscale scores, letting you check whether a respondent's general susceptibility-to-social-consensus trait correlates with their collectivism subscale scores (it need not, by construction — the trait scores are sampled independently of population/demographics).

The important number for validating the experiment's actual hypothesis is the cross-run aggregate (aggregate_indcol_by_population): one row per (population, subscale), averaged across every respondent from every run ever logged in the database — not just the current run. This pooling matters because a single run's per-population n is small (e.g., 3 respondents), so a population-level comparison should be read off the aggregate table, not any individual run's raw numbers. The expectation the experiment is built to test is that indian respondents should show higher Vertical Collectivism (family/group duty, hierarchy acceptance) than western respondents, with ablated (no cultural grounding at all) serving as a baseline that should differ from both grounded populations if demographic grounding is doing anything at all. If ablated scores land close to indian or western, that's a sign the demographic backstory isn't actually moving model behavior — an important null result, not a bug to explain away.

Sub-mode 2: Dilemma Task

What it is

Where the questionnaire measures self-reported cultural orientation directly, the Dilemma Task measures revealed orientation through behavioral choices in concrete scenarios — a different, complementary validation strategy. A single Subject (one persona-conditioned respondent, not a batch) is walked sequentially through 10 fixed hypothetical scenarios, each posing a two-way choice between an individual-benefit option and a family/community-obligation option — for example, taking a dream job in a distant city versus staying near aging parents who need care, or keeping a work bonus for personal use versus giving it to a struggling sibling. Unlike Asch's stimuli, there is no objectively "correct" answer here; this measures value orientation, not perceptual accuracy. Critically, the scenario wording itself is identical for every subject regardless of population — only the persona's already-baked-in demographic/cultural backstory should influence which option gets picked, which is what makes an observed between-population difference attributable to the persona grounding rather than to the wording nudging one way or another. As in the Asch trials, which letter (A/B) holds the individual vs. collective option is independently randomized per scenario per seed, again to prevent a positional-shortcut confound.

Mechanically this runs on the same Sequential engine and MAKE_OBSERVATION/NEXT_ACTION_SPEC/RESOLVE Game Master pattern as Asch (DilemmaGameMaster wraps a shared scenario_engine.ScenarioController), but with just one entity — there's no confederate/social-pressure structure here, it's a pure single-subject decision task. One "session" = one subject stepping through all 10 scenarios once. A single launch can drive multiple sessions back-to-back — one per (population, seed) combination you select — since there's no meaningful way to pause mid-session with useful granularity across many sessions at once, this sub-mode (like the questionnaire) offers no play/pause/step controls, only running → done.

Parameters

From webapp/static/collectivism.html's dilemma panel and dilemma_orchestration.build_dilemma_config:

  • Backend (d-backend) — same fake/ollama choice.
  • Populations (d-populations, checkboxes) — same three options as the questionnaire (indian, western, ablated), each independently toggleable, all checked by default. Same meaning as above: which demographic-grounding sampler (or none, for ablated) generates the single Subject's PsychProfile for each session. The four urbanization-gradient groups exist in the backend but, again, are not exposed in this UI.
  • Seeds (d-seeds, comma-separated text field, default "1") — one or more RNG seeds, e.g. "1,2,3". The run drives one full session (all 10 scenarios) for every (population × seed) combination, back-to-back — so 3 populations × 3 seeds = 9 independent sessions in one launch. Each seed drives both which specific PsychProfile gets sampled for that session's Subject and the A/B option-label randomization for that session's scenarios.

Interpreting results

Each answered scenario produces a DilemmaRecord: subject_name, population, scenario_index, scenario_id (e.g. "career_relocation", "bonus_money", "marriage_choice"), chosen_option (the letter the subject picked), collectivist_choice (boolean — did the picked letter correspond to the family/community-obligation option for that scenario), and the subject's conformity_trait.

The headline metric is collectivist_rate (scoring.collectivist_rate): the fraction of the 10 scenarios in a session where the subject chose the collective-obligation option over the individual-benefit option. 0.0 means the subject always chose individual benefit across all scenarios; 1.0 means always choosing family/community obligation. Because each session only has 10 scenarios, a single session's rate is coarse-grained (each scenario is worth 10 percentage points) — this is even more true here than in Asch, so don't over-read one session's exact rate; running multiple seeds per population and reading the aggregate is essential, not optional.

rate_by_population groups records by population within a single run/response set and reports the collectivist_rate for each — useful for a quick same-run comparison across populations you launched together. The more meaningful, statistically grounded number, though, is the cross-run aggregate_dilemma_by_population (collectivism/instrumentation.py), which pools every scenario answer from every dilemma session ever logged for each population and reports n_runs, n_scenarios, n_collectivist, and the pooled collectivist_rate — again, the point is that a population-level claim should rest on the pooled aggregate across many sessions/seeds, not a handful of sessions from one launch.

As with the questionnaire, the experiment's implicit hypothesis is that indian subjects should show a higher collectivist_rate than western subjects, with ablated (no demographic backstory) providing the no-grounding baseline. Because the Dilemma Task and the INDCOL Questionnaire are two independent measurement approaches to the same underlying construct, a well-validated grounding effect should show up as directionally consistent results across both sub-modes (e.g., indian scoring higher on Vertical Collectivism in the questionnaire and higher collectivist_rate in the dilemma task) — if the two sub-modes disagree about which population is "more collectivist," that's worth investigating rather than picking whichever one confirms your expectation. Also note that conformity_trait is logged per record but is not itself part of the collectivist_rate calculation — it's carried through for post-hoc analysis of whether a subject's general social-susceptibility trait predicts their scenario choices, which it need not, since the two are sampled independently.

Psychology

Ultimatum Game

Fairness vs. self-interest as the stakes rise

What it is

The Ultimatum Game is one of the best-studied paradigms in behavioral economics for testing whether real decision-makers behave like the purely self-interested "rational agents" that classical economic theory assumes, or whether fairness norms override narrow self-interest. In the canonical version, one player (the Proposer) is given a sum of money and must offer some share of it to a second player (the Responder). If the Responder accepts, both keep their respective shares; if the Responder rejects, both get nothing. A perfectly self-interested Responder should accept any offer greater than zero (something is better than nothing), and a perfectly self-interested Proposer who anticipates this should offer close to nothing. In practice, humans reject "unfair" low offers (typically below ~20-30% of the pot) a large fraction of the time, sacrificing money to punish perceived unfairness — a robust finding across dozens of cultures, though the exact rejection threshold varies.

This webapp's implementation is modeled specifically on Andersen, Ertac, Gneezy, Hoffman & List (2011), "Stakes Matter in Ultimatum Games" (American Economic Review), a field experiment run across 8 villages in Meghalaya, northeast India with 458 proposer/responder pairs. Critically, that study used a single-shot, between-subjects design: each pair played exactly once (no repeated rounds, no reputation effects), and it crossed a 2×4 design of wealth condition (no-wealth vs. wealth) against stake size (Rs. 20 / 200 / 2,000 / 20,000). The headline finding was that rejection rates fell sharply as the stakes rose — at the highest stake tested, only 1 rejection occurred out of 24 games, suggesting that "fairness" norms are more salient (relatively cheaper to enforce) when the absolute amount at stake is small.

The simulation reproduces this exactly: for each game, two independent AI agents are instantiated — a Proposer and a Responder — both built from the same Respondent entity prefab (a lightweight persona-only agent with no memory search or observation history; each turn's full prompt is delivered directly as the action-spec's call-to-action text, since there's nothing to remember across a two-turn game). A hand-written, zero-LLM-call UltimatumGameMaster drives exactly two turns: first the Proposer is asked to name a rupee offer out of the stake, then the Responder is shown that exact offer (not a summary — the literal parsed number) and asked to accept or reject. This is implemented via socialnet2/multi_turn_engine.py's shared "interdependent turn" controller, since the Responder's prompt depends on an answer the Proposer hasn't given yet when the game starts. There is no repeated play, no negotiation, and no side communication — exactly one offer, one decision, matching the source study's one-shot field design rather than the multi-round lab version of the game that's also common in the literature.

A full run in this app is a batch: you pick a set of conditions and a sample size N, and the run manager (UltimatumRunManager) drives one freshly-sampled Proposer/Responder pair through each (condition, seed) combination back-to-back, from seed 1 to N, for every selected condition — so selecting 3 conditions with N=10 launches 30 independent two-agent games sequentially in a background thread.

Parameters

  • Backend (ultimatum-backend dropdown; values fake / ollama): fake uses FakeLanguageModel, a deterministic offline stand-in that needs no API key or network access — useful for testing the pipeline instantly, but its "reasoning" is not a real language model's judgment, so treat its output as plumbing-verification only, not psychological data. ollama routes through ollama_backend.build_model, which (despite the name) is wired to call a live model via OpenRouter using credentials/config from your .env file (socialnet2.config.settings); this is the "real" condition that actually produces LLM-driven behavior. An optional host/model_name override exists at the backend-manager level (UltimatumRunManager.start_run's host/model_name kwargs) to point at a different Ollama-compatible chat endpoint or model, but the embedder always stays on the .env default — overriding it independently is explicitly avoided because embeddings need a specific model/host pairing to work correctly.

  • Population (ultimatum-population dropdown; indian / western / ablated): controls how both the Proposer's and the Responder's PsychProfile (Big Five personality + cognitive traits + demographic backstory) are sampled. indian (the default, and the one matching the source study) draws a Census-2011-grounded Indian demographic backstory. western swaps in a lightweight U.S.-marginals demographic sampler instead — a deliberate contrast condition, not a claim of equivalent grounding rigor. ablated skips demographic backstory sampling entirely (with_demographics=False) so the agent has personality traits but no cultural/demographic framing at all — a control for isolating how much of any observed behavior is driven by the backstory versus the bare personality traits. Note: both players in a single game always come from the same population — this is a within-population design, matching Andersen et al.'s own single-country field setup; you cannot currently mix a Western proposer against an Indian responder from this UI. (The code additionally supports the four urbanization.GROUPS names — delhi_urban, chandigarh_urban, haryana_urban, haryana_rural — as a population value at the orchestration layer, but this UI's dropdown does not expose them for Ultimatum.)

  • Conditions — stake × wealth (ultimatum-conditions checkboxes): each checkbox is one of the 8 cells of the 2×4 design: stake ∈ {Rs. 20, 200, 2,000, 20,000} crossed with wealth ∈ {no_wealth, wealth}. Selecting more boxes means more distinct games get run (batched sequentially, not in parallel). The stake is the rupee amount the Proposer must split; the wealth flag toggles whether the Proposer's prompt states they already have Rs. 200 in unrelated prior earnings "kept regardless of what happens next" before offering the split — a simplified stand-in for the source study's real prior-earnings manipulation (there's no simulated earlier task here, just a stated fact injected into the framing text). The code comments are explicit that this approximates the manipulation's substance rather than replicating its exact procedure.

  • Sample size (ultimatum-n, default 4, quick-picks 5/10/20): number of independent games run per selected condition. Since each game uses a freshly seeded pair (seed = 1..N), larger N gives a less noisy per-condition rejection rate and mean offer at the cost of proportionally more LLM calls (2 calls per game when using ollama).

Interpreting results

Each completed game produces one OfferRecord with these fields: condition (e.g. stake_2000_wealth), stake, wealth (bool), proposer_name/responder_name (always literally "Proposer"/"Responder"), offer_amount (the parsed rupee offer, clamped to [0, stake]), offer_fraction (offer_amount / stake — the normalized, cross-stake-comparable number to actually look at), accepted (bool), and offer_parse_failed (true if no integer could be extracted from the Proposer's raw text, in which case the offer silently fell back to stake // 2always check this flag, since a run with many parse failures is measuring the model's ability to follow the "respond with only the number" instruction, not its fairness behavior). Each record also carries proposer_conformity_trait/responder_conformity_trait, the sampled [0,1] conformity scores from each agent's PsychProfile, for post-hoc correlation analysis (e.g. "do higher-conformity proposers offer closer to 50/50?").

At the run and cross-run level, two aggregate statistics are surfaced: rejection_rate (fraction of games where the Responder rejected — this is the primary dependent variable Andersen et al. measured; the literature's typical human finding is that rejection rates drop as stakes rise, precisely what the 4-stake design here is built to test) and mean_offer_fraction (average of offer_fraction across games — human proposers commonly cluster around 0.4–0.5 in lab ultimatum games, though field results, and this simulation's LLM-driven behavior, may differ substantially). The /api aggregate view (aggregate_ultimatum) additionally groups historical results by (stake, wealth) across every run ever stored in the SQLite database (not just the current batch), and explicitly excludes rows with offer_parse_failed = 1 from both the rejection-rate and mean-offer-fraction calculations — so a condition with a high parse-failure rate will show artificially clean-looking aggregate stats computed on a smaller, filtered subsample; always cross-check n_games against how many sessions you actually launched for that condition. With small per-condition sample sizes (the UI defaults to 4), rejection rates are extremely noisy — a single rejection out of 4 games is a 25% rate that could easily reverse with one more sample, so avoid over-interpreting differences between conditions run at the default N.

Psychology

Public Goods Game

Cooperation vs. free-riding in a shared pool

What it is

The Public Goods Game (also called the Voluntary Contribution Mechanism, or VCM) is the standard experimental paradigm economists use to study cooperation versus free-riding. A group of N players each privately and simultaneously decide how much of a personal endowment to contribute to a shared pool. The pool is then multiplied by some factor greater than 1 (representing a genuine social return to cooperation — the pie grows if people pool resources) but less than N (so that, individually, keeping money for yourself always pays more than contributing it, even though the group as a whole is best off if everyone contributes everything). This creates a real conflict between individual and collective rationality: the Nash equilibrium for a purely self-interested player is to contribute zero and free-ride on others' contributions, but if everyone reasons that way, the group ends up strictly worse off than if everyone had cooperated.

This implementation is grounded in Nishi, Christakis & Rand (2017), "Cooperation, Decision Time, and Culture: Online Experiments with American and Indian Participants" (PLOS ONE), whose headline finding — reproduced here as the thing to test — is a cross-cultural round-1 asymmetry: before any social information about neighbors' behavior exists, American subjects were highly cooperative while a majority of Indian subjects defected (contributed zero). Two simplifications are explicitly documented in the code rather than silently assumed: (1) the source study was a repeated, network-structured game, but its key finding concerns only round 1 — the decision made before any information about others exists — so this implementation deliberately models only that single round, since decision-time (the study's other dependent variable) doesn't map cleanly onto an LLM agent's "thinking time" anyway; and (2) the source study's network had partial observability, but since no network structure has had a chance to matter in round 1 regardless, this app instead implements the textbook fully-symmetric closed-group linear public goods game, which is behaviorally identical for the no-information first round being compared.

Mechanically: GROUP_SIZE = 4 players (Player1...Player4), each with ENDOWMENT = 20 tokens, are each asked — completely independently and without seeing each other's decisions — how many of their 20 tokens to contribute to a shared fund. Unlike Ultimatum's two interdependent turns, this is built on socialnet2/scenario_engine.py's shared "many actors, same prompt, no cross-dependency" controller (_build_items gives every player the identical contribution_prompt() text), since round 1 genuinely has no actor depending on another's answer. The pooled contributions are notionally multiplied by MULTIPLIER = 1.6 (chosen within the standard VCM range of 1 < m < n for n=4) and split evenly, though this app's scoring focuses on each individual's raw contribution decision rather than computing final payoffs. A group (the unit of one simulation run) is 4 independently-sampled players from the same population, matching Nishi et al.'s within-country session design; a full run in the UI drives sample_size independently-sampled groups per selected population, back-to-back.

Parameters

  • Backend (public_goods-backend; fake / ollama): identical semantics to Ultimatum's backend selector — fake is a free, deterministic offline stand-in for pipeline testing; ollama calls a real model via OpenRouter per your .env config.

  • Populations (public_goods-populations checkboxes; indian / western / ablated in this UI): selects which demographic-backstory sampler each of the 4 players in a group draws from — same three-way semantics as Ultimatum's population parameter (indian = Census-2011-grounded backstory, western = lightweight U.S.-marginals contrast, ablated = personality traits only, no demographic backstory). You can select multiple populations at once; the run manager then runs sample_size independent groups per selected population (so 2 populations × sample_size 10 = 20 groups = 80 individual contribution decisions). All 4 players within one group always share the same population — there is no mixed-population group option in this experiment. As with Ultimatum, the orchestration layer also accepts the 4 urbanization.GROUPS names (delhi_urban, etc.) as a population value, but this particular UI page does not surface them as checkboxes.

  • Sample size (public_goods-n, default 4, quick-picks 5/10/20): number of independent 4-player groups run per selected population — not the number of individual players. A sample size of 10 with the "indian" population selected launches 10 separate groups (40 total contribution decisions, all independently sampled). Because group_size and endowment are hard-coded constants (stimuli.GROUP_SIZE = 4, stimuli.ENDOWMENT = 20) rather than exposed UI parameters, the only way to change group composition size or endowment magnitude is by editing public_goods/stimuli.py directly and redeploying — this is not adjustable from the web UI.

Interpreting results

Each contribution decision produces one ContributionRecord: condition (formatted as round1_{population}), population, group_size (always 4 in the current build), endowment (always 20), player_name, contribution (the parsed token amount, clamped to [0, 20]), contribution_fraction (contribution / endowment — the normalized number to compare across runs), parse_failed (true if no integer was found in the raw response and it fell back to endowment // 2 = 10 — check this before trusting any given row), and conformity_trait (the player's sampled [0,1] conformity score, carried through for correlation analysis, e.g. testing whether high-conformity players contribute more when told the group "expects" cooperation — though note round 1 by construction gives players no information about what others expect, so this trait's effect here is really about baseline disposition, not responsiveness to observed social pressure).

Two summary statistics matter most: mean_contribution_fraction (average contribution_fraction across all players in the run/population — the primary cooperation measure; Nishi et al.'s finding predicts this should be markedly higher for a "western"-flavored population than an "indian"-flavored one in round 1) and defection_rate (the fraction of players who contributed exactly zero — described in the code as "the cleanest binary read" of the source paper's specific claim that "a majority of Indian subjects defected"; this is the most direct number to check that finding against, since it's a binary yes/no rather than a continuous average that could be pulled by a few high or low outliers). The cross-run aggregate query (aggregate_public_goods) groups every stored trial by population across all runs ever recorded, again excluding parse_failed = 1 rows from both statistics — so watch n_players per population in the aggregate to confirm you're not comparing a population with many parse failures (small effective N) against one with none. As with all these experiments, small sample sizes make single-run comparisons noisy: with the UI's default of 4 groups (16 players) per population, a defection-rate difference of even 25 percentage points between populations could be a handful of individuals swinging the count, so favor the cross-run aggregate (which accumulates every run ever recorded in your local SQLite database) over any single batch when drawing conclusions.

Psychology

Caste Stereotype Threat

Does naming an identity degrade performance?

What it is

Stereotype threat is the psychological phenomenon in which making a negative stereotype about one's group salient — even implicitly, even without anyone saying anything discouraging — measurably degrades performance on a task in the stereotyped domain, purely through the anxiety and cognitive load of worrying about confirming the stereotype. The canonical demonstrations are Steele & Aronson's work on Black American students' test performance and analogous studies on women and math, but the specific design implemented here follows Hoff & Pandey (2006), "Discrimination, Social Identity, and Durable Inequalities" (American Economic Review): rural North Indian junior-high boys solved mazes for piece-rate pay under three conditions — caste anonymous, caste publicly revealed in a mixed-caste group, and caste publicly revealed in a caste-segregated group. The finding was sharply asymmetric: low-caste (Scheduled Caste/Scheduled Tribe) performance dropped specifically when caste was made salient, while upper-caste ("General") performance was essentially unaffected — evidence that the effect is a genuine identity-threat mechanism, not just "revealing any information changes behavior."

This simulation substitutes a text-only puzzle task for real maze-solving (the same design choice asch/trials.py makes for line-judgment): a deterministically-generated, deterministically-graded script of numeric sequence-completion puzzles (generate_puzzle_script) with three multiple-choice options (A/B/C) and exactly one correct answer, no LLM grading involved. Difficulty ramps in thirds across the script — DEFAULT_N_TRIALS = 12 trials, the first third at difficulty 1 (simple constant-step arithmetic sequences), the middle third at difficulty 2 (sequences with a variable, possibly negative step), and the final third at difficulty 3 (geometric/multiplicative sequences) — mirroring the source study's practice of varying task difficulty, though not with an identical ladder. Real caste categories (Scheduled Caste, Scheduled Tribe, General) are not fabricated for this experiment — they come from the same Census-2011-grounded demographic sampler (components.demographics) used throughout the app, so a subject's social_category is a genuine property of their sampled backstory, not a label invented for this task specifically.

The three conditions are anonymous (caste never mentioned), caste_revealed_mixed (an IdentitySalience prompt component neutrally states that the subject's social category is being announced and recorded alongside a mixed-category group of test-takers), and caste_revealed_segregated (the same salience framing, but stating the subject has been grouped specifically with others sharing their own social category). Critically, the IdentitySalience text is deliberately non-instructional — it states facts ("your caste is being recorded," "you're grouped with others of your caste") with zero "try harder" or "you might struggle" language, because any performance-directive wording would confound simple instruction-following with a genuine stereotype-threat effect, which is exactly the confound this design exists to avoid.

The critical design choice, called out explicitly in the orchestration code's docstring, is that each subject is sampled once and then run through all three conditions using that same sampled profile — not three independently-sampled subjects, one per condition. This is a paired within-subject design specifically to avoid confounding the condition manipulation with trait/demographic sampling variance across subjects (the code notes this was "found the hard way" in an earlier Asch-experiment mistake). A run in the UI therefore always launches sample_size subjects × 3 conditions = sample_size × 3 total sessions, each session being one subject stepping through the full 12-trial puzzle script under one condition.

Parameters

  • Backend (caste_threat-backend; fake / ollama): same semantics as the other experiments — fake for free, deterministic offline pipeline testing; ollama for real model calls via OpenRouter.

  • Ablate (caste_threat-ablate checkbox, labeled "Ablate (drop IdentitySalience — no-component control)"): when checked, the IdentitySalience prompt component is not attached to the subject at all (enable_identity_salience=False), regardless of which of the 3 conditions is nominally being run — so all three "conditions" become behaviorally identical no-salience control sessions. This exists as a methodological check: if performance still differs across the three nominal conditions with ablation on, something other than the identity-salience text itself is driving the difference (e.g. a subtle wording difference elsewhere, or noise), which would undermine confidence in the non-ablated result. Comparing an ablated run against a normal run is the way to sanity-check that the effect (if seen) is actually caused by the salience manipulation.

  • Sample size (caste_threat-n, default 4, quick-picks 5/10/20): number of subjects, each run paired across all 3 conditions, so total sessions launched = 3 × sample_size, and total puzzle trials = 3 × sample_size × 12. Because each subject's social_category is only known after sampling (it's drawn from the same Census demographic distribution used elsewhere in the app — you don't get to pre-select "give me an SC subject"), the population split across social categories in any given batch is itself random; a small sample size can easily yield very few SC/ST subjects by chance, since General ("upper caste" / unreserved) categories are the Census-2011 majority in most sampled states.

  • Two parameters exist in the underlying orchestration.build_condition_config function but are not exposed in this UI: trial_seed (defaults to the subject's seed; independently controls puzzle generation so a reused subject also sees the identical puzzle set across their 3 conditions, isolating the condition as the sole manipulation) and direct control over n_trials (hard-coded to DEFAULT_N_TRIALS = 12 in stimuli.py, changeable only by editing source).

Interpreting results

Each individual puzzle attempt produces one TrialRecord: subject_name (always "Subject"), condition (anonymous / caste_revealed_mixed / caste_revealed_segregated), social_category (the subject's real sampled caste category — "Scheduled Caste," "Scheduled Tribe," or "General"), trial_index (0–11), difficulty (1–3), correct_option/chosen_option (the "A"/"B"/"C" labels), correct (bool, chosen_option == correct_option), and conformity_trait. Since correctness is graded deterministically against a known right answer (unlike Attribution Style's free-text classification), there's no parse-failure ambiguity to check here the way there is for Ultimatum or Public Goods — a chosen option outside {A,B,C} would simply score as incorrect.

The key run-level metric is completion_rate (fraction of the subject's 12 trials answered correctly) — but the metric that actually tests Hoff & Pandey's finding is the cross-run aggregate, aggregate_caste_threat, which groups every trial ever recorded across every run by (condition, social_category) and reports completion_rate per cell. The prediction to check the results against is specific and directional: completion rate for SC/ST subjects should drop noticeably between anonymous and the two caste-revealed conditions (with segregated potentially showing a larger or different effect than mixed, since the source study distinguished them), while General subjects' completion rate should stay roughly flat across all three conditions. If you instead see General's performance also dropping under caste-revealed conditions by a similar margin to SC/ST's, that's evidence the salience text itself is somehow generically distracting or confusing rather than triggering a genuine identity-specific threat effect — worth checking against an ablated run for comparison. Because puzzle difficulty rises across the 12-trial script (trials 0–3 easy, 4–7 medium, 8–11 hard), a subject who runs out of steam partway through will show a completion-rate drop that has nothing to do with condition — if you want to isolate the stereotype-threat signal from a pure difficulty effect, look at per-difficulty-tier accuracy (available in the raw trial records) rather than only the flat overall completion rate. As with the other experiments, small sample sizes are the biggest interpretive risk: since social_category is randomly sampled rather than deliberately balanced, a batch of sample_size=4 might contain only one SC/ST subject, in which case that subject's individual variance (rather than a real population-level effect) is entirely what any observed "SC/ST underperformed under caste-revealed conditions" result would be built on. Favor running larger sample sizes or relying on the cross-run aggregate (which accumulates every session ever stored in your SQLite database, not just your current batch) before treating any pattern as reliable.

Psychology

Attribution Style

Dispositional or situational — how agents explain behavior

What it is

Attribution theory studies how people explain the causes of others' behavior, and one of its most replicated cross-cultural findings is a difference in default explanatory style: given an ambiguous negative social event (someone missed an appointment, someone was rude, someone didn't help), do you explain it by pointing to the other person's disposition — their character, personality, or choices ("they're lazy," "that's just rude") — or to their situation — external circumstances beyond their control ("they must have had an emergency," "traffic," "work obligations")? This experiment operationalizes Miller, J.G. (1984), "Culture and the Development of Everyday Social Explanation" (Journal of Personality and Social Psychology), whose finding was that American respondents skew dispositional (explaining behavior via the actor's character or choices) while Hindu Indian respondents skew situational (explaining behavior via circumstances or social obligations) — a classic piece of evidence for individualist-vs-collectivist differences in social cognition.

Unlike Ultimatum, Public Goods, or Caste Threat, this experiment has no correct answer to measure against — there is no "right" way to explain why a coworker missed a deadline. What's being measured is a style, not accuracy or performance, and the design takes real care to keep the measurement clean: the module docstring is explicit that the 8 scenario prompts are fixed and culturally neutral, identical for every subject regardless of population, so that any observed difference between populations can be attributed to the persona's demographic/cultural backstory (already baked into the PsychProfile before the subject ever sees a scenario) rather than to the scenario wording itself nudging the model toward one style or another.

Mechanically, one Subject entity (again the lightweight Respondent prefab, no memory search needed) is built with a PsychProfile sampled for a chosen population, then stepped sequentially through all 8 fixed scenarios via the Sequential engine (same execution pattern as Asch), one free-text response per scenario — e.g. "A close friend did not attend your family's important celebration... Why do you think they didn't come? Explain in one or two sentences." Each response is then run through a deterministic keyword classifier (classify_attribution), not graded by a second LLM call — the module's own docstring flags this explicitly as "not a validated psychometric instrument," describing it as "a natural candidate to upgrade to an LLM-based classifier later," kept as simple keyword matching for now purely for determinism and testability, the same tradeoff ultimatum/scoring.py's numeric parser makes. A session is one subject working through all 8 scenarios; a run in the UI drives sample_size independently-sampled subjects per selected population through their own full 8-scenario session, back-to-back.

Parameters

  • Backend (attribution-backend; fake / ollama): identical semantics to every other experiment in this suite. Note that with the fake backend, responses come from a deterministic template generator, not genuine free-text reasoning — since this experiment's entire measurement depends on nuanced natural-language content (the specific words used to justify a behavior), the fake backend is essentially useless for actually testing the attribution-style hypothesis and should be treated as pipeline-verification only, even more so than for the other experiments.

  • Populations (attribution-populations checkboxes; indian / western / ablated in this UI): controls the subject's sampled demographic backstory exactly as in Ultimatum/Public Goods — indian draws a Census-2011-grounded backstory (the "Hindu Indian" side of Miller's contrast, approximately), western draws a lightweight U.S.-marginals backstory (the "American" side), and ablated strips demographic backstory entirely, leaving only the population-independent Big Five/cognitive trait scores — a useful baseline for checking how much of any dispositional/situational skew survives with no cultural framing at all. As elsewhere, the 4 urbanization.GROUPS names are accepted by the orchestration function but not exposed as checkboxes on this page.

  • Sample size (attribution-n, default 4, quick-picks 5/10/20): number of independently-sampled subjects per selected population; each subject answers all 8 fixed scenarios, so sample_size=10 with 2 populations selected yields 20 subjects × 8 scenarios = 160 individual free-text responses to classify. The scenario set itself (default_scenarios(), 8 fixed prompts covering missed celebrations, missed deadlines, not helping a neighbor, missed homework, not calling during a hard time, a rude shopkeeper, leaving work early, and arriving late to a wedding) is not configurable from the UI — changing it requires editing attribution/stimuli.py.

Interpreting results

Each scenario response produces one AttributionRecord: subject_name, population, scenario_index (0–7), scenario_id (a short slug like missed_celebration or rude_shopkeeper), raw_text (the subject's actual free-text explanation — worth reading directly, since the classifier is a blunt instrument and the raw text is the ground truth), attribution (one of "dispositional", "situational", "mixed", or "unclear"), and conformity_trait. The classification logic (classify_attribution) is a simple two-list keyword scan: it checks the lowercased response text against a fixed DISPOSITIONAL_KEYWORDS tuple (words like "lazy," "irresponsible," "selfish," "his/her/their fault," "chose not to," "bad attitude") and a fixed SITUATIONAL_KEYWORDS tuple (words like "family," "emergency," "couldn't," "had to," "no choice," "obligation," "traffic," "illness," "financial," "duty"). If only dispositional keywords match, the response is "dispositional"; if only situational keywords match, it's "situational"; if both lists match, it's "mixed"; if neither matches, it's "unclear". This means a perfectly clear, well-reasoned explanation that happens not to use any of these specific stock words or their synonyms will be scored "unclear" and effectively dropped from the headline rate (both situational_rate and the SQL aggregate explicitly exclude "unclear" rows from their denominators) — so a high "unclear" count for one population relative to another is itself worth investigating (it could mean that population's phrasing style genuinely differs in ways the fixed keyword lists don't capture, which would bias the situational_rate comparison even before considering the actual dispositional/situational split).

The key metric is situational_rate: the fraction of classified (non-"unclear") responses labeled "situational", computed both per-run and, via aggregate_attribution, across every run ever stored in the database, grouped by population. Miller's finding predicts this should be markedly higher for the "indian" population than for "western" — i.e., Indian-backstoried subjects should more often explain the same ambiguous negative event by pointing to circumstances/obligations, while Western-backstoried subjects should more often point to the other person's character or choices (which would show up as a lower situational_rate, since more of their classified responses land in "dispositional"). Because the classifier only sees keyword presence, not sentiment or logical structure, treat situational_rate as a rough, reproducible proxy rather than a validated measure — for any specific comparison you care about, it's worth spot-checking a sample of raw_text responses by eye to confirm the keyword classification actually matches your own reading of dispositional-vs-situational intent, especially for edge cases classified as "mixed". As with the other experiments, small per-population sample sizes (the UI defaults to 4 subjects × 8 scenarios = 32 responses per population) make single-run situational_rate differences easy to over-read; the cross-run aggregate accumulates every session ever recorded locally and is the more reliable number to lean on once you've run more than one batch.

Psychology

Generator

Psychology Lab — an LLM pipeline that writes new experiments from a text prompt

What it is

Every other page in this app runs one fixed, hand-built experiment (Asch conformity, the Ultimatum Game, and so on). The Generator is different in kind: it is not an experiment, it's an experiment factory. You describe a psychology paradigm you want in a sentence or two of free text (e.g. "a framing effect experiment: same medical outcome described as a survival rate vs. a mortality rate, and whether people choose it"), and the system uses two separate LLM stages — a drafting model and a coding model — to turn that description into a brand-new, fully wired Concordia experiment module living under src/socialnet2/generated/<slug>/, ready to launch a real simulation run against.

The pipeline runs in three distinct phases, each a separate user-visible step:

1. Drafting (spec_drafting.draft_spec). A single schema-constrained LLM call (Ollama's format=<json schema> grammar-constrained decoding, or OpenRouter's structured-output equivalent via openrouter_backend.sample_json) turns your description into a formal ExperimentSpec: a slug, display name, citation, a 2-4 sentence paradigm_description, and — depending on which of four modes the model picks — a list of 6-8 fully concrete scenarios. The prompt (_INSTRUCTIONS in spec_drafting.py) is explicit that "a separate coding model will mechanically transcribe your draft into code — it will NOT invent or improve any content," so every scenario must already be complete and ready to show a subject verbatim (real numbers and names, never a placeholder like "X%" or "[amount]"). The four modes map to fundamentally different experiment shapes: choice (subject picks between two labeled options — for value-tradeoff/preference/conformity paradigms), freetext (subject writes 1-2 sentences, later classified by an LLM — for attribution/explanation paradigms), numeric (subject answers with a single number — for anchoring/estimation paradigms), and ultimatum (a parametrized two-role proposer/responder negotiation with no scenario list at all, just stake/currency/wealth-priming constants). This drafting step deliberately runs at low temperature (0.2) with an above-default repeat_penalty (1.3) because the code comment notes the local model was observed "degenerating into an unbounded repeated-phrase loop" inside free-text fields at default settings, which corrupts the JSON output.

2. Review. The drafted spec is rendered back to you as editable JSON before anything is generated — spec_drafting.validate_spec() is run both immediately after drafting and again after any manual edits you make, specifically so "a user-edited spec can't silently skip validation." This is the pipeline's core safety rail: a lower-reliability model (a local Ollama model) does the drafting, so nothing gets generated until a human signs off on the concrete content.

3. Generation (pipeline.generate). Once you approve the spec, a two-stage aider (an AI pair-programming CLI) pipeline writes the actual code, invoked as a subprocess against ollama_chat/<coder_model> or, if settings.llm_provider == "openrouter", openrouter/<coder_model>. Stage 1 writes stimuli.py + scoring.py plus offline unit tests for them, instructed (via a long, precise prompt) to copy the exact structure of a proven hand-written precedent module (e.g. collectivism/dilemmas.py for choice mode, attribution/stimuli.py for freetext/numeric, ultimatum/stimuli.py for ultimatum) — the prompt explicitly says "Do not touch Concordia/entity/game-master code yet — that's a later step," enforcing a stage boundary so the coder model can't wander into the harder, unit-testable Concordia wiring while still writing basic data classes. Stage 1's output is checked with pytest before Stage 2 ever starts. Stage 2 then writes game_master.py + orchestration.py, adapting an existing generic module (attribution/game_master.py/orchestration.py, or collectivism/dilemma_gm.py/dilemma_orchestration.py) into the new experiment's names and fields, deliberately not writing unit tests for these two files since Concordia's RESOLVE step "is only exercised end-to-end" — instead a full offline pytest tests/ regression run is run afterward to confirm nothing else broke, followed by a smoke check that actually imports the new orchestration.py and calls build_condition_config(population="indian", seed=1). If that smoke check fails, the pipeline runs one bounded automatic repair round — it hands the coder model the exact traceback and asks it to fix game_master.py/orchestration.py without touching stimuli.py/scoring.py unless the bug is genuinely there — then retries the smoke check once more before giving up.

Parameters

  • Description (f-description textarea) — the free-text paradigm description. This is the only creative input; everything else is generated from it.
  • Draft model / host (DraftSpecRequest.model, default "qwen2.5-coder:32b"; .host, default "http://192.168.29.156:11434") — which Ollama model and server drafts the spec. Not exposed as visible form fields in the HTML you'd normally see, but present in the request schema with these defaults.
  • Spec JSON (f-spec-json, editable textarea) — the full drafted ExperimentSpec as raw JSON, editable before generation: slug, display_name, citation, paradigm_description, mode, the mode-specific scenario list(s), field names (target_field_name, rate_field_name, numeric_field_name), keyword lists (target_keywords/baseline_keywords, each needing ≥3 entries), and for ultimatum mode ultimatum_stake (default 100), ultimatum_currency_symbol (default "$"), ultimatum_resource_name (default "dollars"), ultimatum_wealth_priming (bool, default False), and ultimatum_wealth_endowment (int, only meaningful if priming is on).
  • Coder model / host (GenerateRequest.coder_model, default "qwen2.5-coder:32b"; .coder_host, same default IP) — which model runs the aider pipeline stages.
  • Backend (f-run-backend dropdown) — "fake" (offline, deterministic, "validates wiring only") or "ollama" (a live run, actually routed through OpenRouter per the option's own label "live (OpenRouter, via .env)"). This governs the simulation run after code generation, not the generation itself.
  • Populations (f-run-population checkboxes) — indian (checked by default), western, ablated. Each checked population gets its own simulated respondent profile via psych_profile_lib.sample_profile, run independently.
  • Seeds (f-run-seeds, comma-separated text field, default "1") — one run is launched per (population × seed) combination (itertools.product), so 3 populations × 2 seeds = 6 independent runs.

Interpreting results

The UI shows a Pipeline stages panel with one collapsible row per stage (stage1, stage1_test, stage2, stage2_test, smoke_check, and repair/smoke_check_retry if a repair round fired), each marked with success or failure and its full aider/pytest log tail visible inside. If any stage fails, the pipeline stops there and the run's status becomes "error" with the message "Pipeline failed — see stage_logs for the failing stage's output" — this is where you diagnose what went wrong: a Stage 1 pytest failure usually means the coder model got a field name or parsing edge case wrong in stimuli.py/scoring.py; a failed smoke check even after repair usually means a dropped import or mismatched attribute reference in the generated orchestration.py/game_master.py (the code comments call this "a real bug in practice" that the smoke check exists specifically to catch, since plain pytest structurally can't reach it).

Once generation succeeds and the population/seed sweep runs, each combination renders as its own results panel: a large percentage or mean (formatRate — shown as a percentage for choice/freetext/ultimatum modes, or as a raw decimal mean for numeric mode, detected by whether rate_name starts with "mean_"), a subtitle giving rate_name, population, seed, record count, and conformity_trait (the sampled respondent's personality-trait value driving the run), and an expandable per-scenario or per-game response table (columns differ by mode — e.g. scenario_id/chosen_option/<target_field_name> for choice mode, proposer_name/responder_name/offer_amount/offer_fraction/accepted for ultimatum). Below the individual runs, an aggregate table rolls up every persisted run of this experiment slug (or, for ultimatum mode, every ultimatum run ever recorded in the shared table, grouped by stake × wealth condition) into population-level rates or means with n_runs/n_records counts — this is the number to trust for a real read on the effect, since any single seed's rate is one noisy sample. A "fake" backend run validates that the generated code executes end-to-end without errors but its rate numbers are meaningless (deterministic canned responses); only an "ollama"-backend run's rates reflect an actual model's behavior on the generated paradigm.

Economics

Economics Lab

Free-text market, bargaining, and commons scenarios

What it is

Economics Lab (/economics_lab) is the free-text economics-experiment designer: instead of picking from a fixed list of scenarios, you describe an economic situation in plain English and an LLM turns it into a fully-specified, runnable Concordia simulation. Under the hood it is a two-step pipeline handled by webapp/economics_lab_run_manager.py, which drives the vendored examples.economics_lab package (vendor/concordia_examples/examples/economics_lab/):

  1. Design (designer.design_experiment): your free-text description is wrapped in a large schema prompt (spec.SCHEMA_PROMPT) and sent to the chat model. The model must choose one of three "game types" — market, bargaining, or collective_action — and emit a single JSON object matching that type's schema. The designer extracts the first balanced {...} object from the model's raw text (robust to markdown fences or commentary), parses it, and runs it through spec.validate_spec. If validation fails, it retries once (max_attempts=2 by default) with the validation error fed back into the prompt so the model can self-correct.
  2. Run: once you have a validated spec (either designed or hand-edited/pasted as raw JSON into the spec panel), it's dispatched to one of three builders — market_builder, bargaining_builder, or collective_action_builder — keyed by spec["game_type"].

The three game types model genuinely different economic mechanisms:

  • market: goods trade between named "producer" and "consumer" agents via Concordia's MarketPlace game master component (marketplace__GameMaster, from concordia.contrib.components.game_master.marketplace). Each round, every agent submits bid/ask orders for goods, and the game master clears them either as a double auction (clearing_house) or against a posted catalog (fixed_prices). This is the same underlying mechanism India Marketplaces and Financial Markets use.
  • bargaining: exactly one buyer and one seller negotiate the price of a single item over num_games rounds of back-and-forth offers, adapting examples/games/haggling's simulation. Prices are always expressed internally on a fixed 1–5 "coins" scale.
  • collective_action: a group of 3+ players share a common-pool resource (fish, grazing land, irrigation water, or network bandwidth) that depletes with overuse and regenerates over time — a "tragedy of the commons" setup adapted from examples/resource_dilemma, run across num_cycles extraction rounds.

A distinctive implementation detail: every agent's persona/goal text is prefixed with a demographic backstory sampled from Census 2011 India data (socialnet2.components.demographics.sample_demographic_profile) — age, state, religion, education, etc. — freshly sampled per build. This grounds any free-text economic scenario you describe (even a generic "monopolist selling watches") in the real Indian population distribution, the same convention socialnet2's native experiments (ultimatum, public_goods) use via population="indian".

A run produces a results object (the full Concordia simulation log) and a market_state snapshot (the final state of whichever mechanism was used), returned to the UI as JSON with NaNs converted to null (_json_safe, since strict JSON has no NaN literal and MarketPlace uses math.nan for untraded goods).

Parameters

Top-level UI controls (webapp/static/economics_lab.html): - Backend (#backend, select): ollama ("live, via OpenRouter/.env") or fake (a deterministic mock model, run-only — useful for testing the pipeline without burning API budget or waiting on real inference). - Description (#description, textarea): your free-text prompt for the Design step, e.g. "A monopolist seller of a rare vintage watch facing three buyers with very different willingness to pay." - Design button: sends the description to design_experiment, populating the spec panel. - Spec (#spec-json / #spec-table): the resulting JSON spec, directly editable — you can hand-tune any field before running, or skip Design entirely and paste a spec you wrote yourself. - Max steps override (#max_steps, number, 1–30): overrides spec["max_steps"] at run time. Explicitly labeled "market only" because bargaining and collective_action ignore this and use their own round-count fields instead (num_games, num_cycles). - Run button: validates the spec (again, deterministically, via spec_lib.validate_spec) and dispatches to the matching builder.

Spec fields by game type (from spec.py's schema, all enforced by validate_spec):

market: market_type ("clearing_house" double-auction or "fixed_prices" posted-catalog; default clearing_house), max_steps (clamped 2–12, default 6), goods (list of {id, category, quality, price, inventory} — at least 2 required; under fixed_prices every good needs non-null price and inventory), agents (list of {name, role: "producer"|"consumer", cash, inventory, goal} — at least 3 required; under fixed_prices every agent must be a consumer since producers have no effect in that mode; under clearing_house you need at least one of each role).

bargaining: product_description (what's being negotiated), buyer_name/seller_name (must differ), buyer_value_min/buyer_value_max and seller_cost_min/seller_cost_max (integers 1–5, min≤max), num_games (1–5, how many negotiation rounds), currency_explainer (a sentence mapping the abstract 1–5 "coin" scale onto real story units, e.g. "each coin represents Rs. 1000"). Internally, num_games also drives MAX_STEPS = 40 + 30 * num_games engine steps. Note a documented quirk: the underlying create_player_pairs helper randomly assigns which named person plays buyer vs. seller — with only two players this is a one-time coin flip per run, and the result's market_state reports the realized buyer/seller (which may not match your buyer_name/seller_name intent) alongside the intended ones, so this is visible rather than silently wrong.

collective_action: domain ("fishery", "pasture", "irrigation", or "network" — pick whichever real resource best matches your scenario), num_cycles (clamped 2–10, default 6), players (list of {name, background, motivation} — at least 3 required, motivation describing how aggressively they extract from the shared resource). Resource capacity and regeneration rules are each domain's own hardcoded defaults (not exposed as spec fields) — only "standard" mode (no elections) is used.

Interpreting results

The market_state you get back depends entirely on which game type ran:

For market: this is the MarketPlace component's full serialized state (get_state()), including: - agents: final {cash, inventory, role, queue} per named agent — compare against their starting cash/inventory to see who ended up net better or worse off, and whether producers/consumers behaved as their goal text intended. - goods: static catalog metadata. - history: one {good_id: clearing_price} dict per round — the round-by-round price series. Watch for convergence (prices stabilizing round over round, suggesting the market found an equilibrium) vs. divergence or oscillation (prices swinging widely, suggesting thin liquidity, agent confusion, or an unrealistic spec). A NaN/null price for a good in a given round means no trade cleared for it that round (e.g. no crossing bid/ask). - curve_history: the cumulative supply/demand curve data per round per good, useful for reconstructing the order book shape. - trade_history: a full log of every order and whether it resulted in a trade — this is where you check volume (how much actually changed hands, not just posted prices) and look for one-sided rounds (only bids, no asks, or vice versa) that signal a broken or unbalanced spec. - orderbooks: any still-open orders at the end of the run.

For bargaining: market_state is much smaller — realized_buyer/realized_seller (who actually played which role), intended_buyer/intended_seller (what you specified), joint_action (the final agreed price/terms, if a deal was struck), and scores (each player's payoff — for the buyer, value − price_paid; for the seller, price_received − cost). A negotiation that fails to converge shows up as an empty or null joint_action; heavily skewed scores (one party capturing nearly all the surplus) indicates one-sided bargaining power, which is worth checking against whether buyer_value_*/seller_cost_* ranges left much surplus to split in the first place.

For collective_action: market_state is {"step_logs": summary}, a JSON summary from resource_logger.ResourceLoggerState — a cycle-by-cycle trace of the shared resource's remaining stock, each player's extraction that cycle, and (implicitly) whether the resource collapsed (hit zero) before num_cycles completed or sustained itself. The core thing to look for is the tragedy-of-the-commons signature: individually-rational over-extraction driving the shared stock toward exhaustion, versus cooperative restraint that keeps it renewing. Compare motivation text (aggressive vs. conservative extractors) against actual extraction numbers to see whether the LLM-played agents acted consistently with their assigned personas.

Gotchas across all three: small agent counts (the schema's minimums are only 3 agents/players, or 2 for bargaining) mean results are highly sensitive to individual LLM response variance — don't over-interpret a single run as "the" outcome of a given spec; re-run a few times if you want a stable read. The fake backend produces a fixed, deterministic mock response regardless of prompt, so it's useful for confirming the pipeline works but tells you nothing about actual agent behavior — always switch to ollama/live for a real read on the economics. Watch the live log panel (#run-log / #design-log) during a run: LLM refusals, malformed JSON, or validation retries all print there, and Design silently exhausting its max_attempts=2 retries surfaces as an error rather than a partial spec.

Economics

India Marketplaces

Five hand-built Indian market microstructures

What it is

India Marketplaces (/india_marketplaces) is a fixed, curated set of five vendored scenarios (vendor/concordia_examples/examples/india_marketplaces/), each modeling a specific real-world Indian market microstructure using the same underlying MarketPlace game-master mechanism as Economics Lab's market game type — but here every scenario, its agents, goods, and prices are hand-authored (not LLM-designed), so there's no "Design" step: you pick one of five, optionally override its round count, and run it.

The five scenarios (each a Python module under examples/india_marketplaces/, each defining SCENARIO_INFO with a number, name, market_type, description, and run function):

  • 0 — Ganjbazar Mandi Onion Auction (clearing_house): farmers and arhatiya (commission agent) traders run a bid/ask double auction for onions after a weak monsoon, testing price discovery around an informal MSP (minimum support price) floor.
  • 1 — Bharat FMCG Fixed-Price Catalog (fixed_prices): kirana (small neighborhood) store owners buy from a wholesale distributor's new fixed-price, limited-stock digital catalog, testing whether posted pricing displaces the traditional phone-order haggling relationship.
  • 2 — Silk Board Evening Surge (clearing_house): auto-rickshaw and cab drivers versus riders run a decentralized bid/ask auction for ride-minutes during Bengaluru's notorious evening gridlock, as an alternative to platform-set algorithmic surge pricing.
  • 3 — Secondary GPU Bazaar (clearing_house): used-GPU sellers and buyers run a bid/ask double auction, testing price discovery and how disclosed mining history (a GPU's wear/risk signal) affects trust and clearing prices.
  • 4 — Fair Price Shop PDS Leakage (fixed_prices): ration-cardholder households buy subsidized staples at a fixed price from a government Fair Price Shop (part of the Public Distribution System) whose stock consistently falls short of the official quota — testing who gets squeezed out when a guaranteed entitlement becomes an artificially scarce good.

Each scenario is deliberately paired: two use clearing_house (open bid/ask price discovery) and two use fixed_prices (posted price, the question being who gets served before stock runs out), letting you compare mechanism design directly. Scenario 0 and 2 are essentially about whether a fair price is discovered under auction dynamics; scenario 1 and 4 are about rationing under a fixed price when supply is short; scenario 3 adds a trust/information dimension (disclosed condition) on top of open auction dynamics.

Because these are fixed scenarios, webapp/india_marketplaces_run_manager.py is much simpler than the Economics Lab manager — no design/validate step, just run_lib.SCENARIOS[scenario_number]["run"](model, embedder, max_steps, output_dir).

Parameters

There are only three controls on this page (webapp/static/india_marketplaces.html):

  • Backend (#backend, select): ollama ("live, via OpenRouter/.env") or fake (mock model, labeled "structural test only" here — since these scenarios are fixed/curated, running them on fake is purely a smoke test of the plumbing, not a meaningful economic result).
  • Scenario (#scenario-list, radio buttons, one per SCENARIO_INFO entry, scenario 0 selected by default): choose which of the five fixed scenarios to run. Each radio's label/description is populated live from GET /api/india_marketplaces/scenarios (backed by list_scenarios()), so the UI always reflects whatever scenarios are registered server-side.
  • Max steps (#max_steps, number, 1–20, placeholder "scenario default"): optionally overrides the scenario's own hardcoded round count (e.g. scenario 0 defaults to 6 rounds via default_max_steps=6 in its build_config). Leave blank to use the scenario's authored default, which was tuned by the scenario's author to fit its narrative arc (e.g. enough rounds for a shock or a price-discovery process to play out).

There is no free-text or spec-editing surface here at all — unlike Economics Lab, the goods, agent count, cash endowments, and personas for each scenario are fixed in the vendored source and not exposed as run-time parameters. If you want to vary an India-market scenario's structure, the closest option is to describe a similar situation in Economics Lab's free-text builder instead (which will ground it in the same Census 2011 demographic sampling, just without India Marketplaces' hand-tuned narrative detail).

Interpreting results

Results come back as {"scenario_name": ..., "market_state": ...}, where market_state is the same MarketPlace.get_state() shape described under Economics Lab's market type: agents (final cash/inventory per named participant), goods (catalog), history (round-by-round clearing prices per good), curve_history (supply/demand curve snapshots), trade_history (the full order/trade log), and orderbooks (any orders left unfilled at the end).

What to look for is scenario-specific:

  • Scenario 0 (Mandi Auction): does the clearing price for onions stabilize near a level consistent with the "informal MSP floor" premise, or does it collapse below it (farmers accepting distress prices) or spike unrealistically? Check trade_history for whether farmers routinely walked away rather than sell below their floor — that's the qualitative signal the scenario is testing for.
  • Scenario 1 (FMCG Catalog): since this is fixed_prices, there's no price discovery to watch — instead check trade_history's fill_rate behavior (implicitly, how many kirana owners' orders actually got filled before the limited stock in goods[].inventory ran out) and whether the catalog's fixed pricing measurably changed who got served versus a haggling-based counterfactual (which this scenario doesn't itself run — you'd need to compare against a clearing_house variant).
  • Scenario 2 (Ride Surge): watch whether the decentralized bid/ask mechanism converges to a "surge-like" elevated clearing price during high demand, and compare the resulting price level and trade volume qualitatively against what a platform's algorithmic surge multiplier would have produced — the scenario's whole point is testing this alternative pricing mechanism.
  • Scenario 3 (GPU Bazaar): look at whether clearing prices differ (in history) or trade success differs (in trade_history) between GPUs with disclosed heavy mining history versus clean ones — a working "trust discount" should show up as a persistent price gap or as buyers passing over risky listings in the orderbook.
  • Scenario 4 (PDS Leakage): since supply is fixed and short of the official quota by design, the key metric is rationing outcome — which households' orders in trade_history got filled and which didn't, and whether that correlates with anything observable about the household (arrival order, persona traits) versus being effectively random. This is the scenario most directly testing an equity/leakage question rather than a price-discovery question.

Gotchas: because these scenarios are short (most default to 6 rounds) and involve a handful of hand-authored agents, single-run results carry substantial LLM-response noise — the same caveat as Economics Lab applies: don't treat one run as definitive, especially for the more qualitative scenarios (0, 2, 3) where the "finding" is a pattern across rounds rather than a hard number. The fake backend is explicitly not meant to produce meaningful economic behavior here — use it only to confirm a scenario runs end-to-end without errors before spending live-model budget on it.

Finance

Financial Markets Builder

Free-text market design — including LLM-written mechanics

What it is

The Financial Markets Builder (/financial_markets) is the free-text counterpart to India Marketplaces' fixed scenarios and structurally the most involved page in the app — it can, in the custom mechanic case, have an LLM write and execute new Python code as part of building your experiment, which is why it has an explicit human-review gate before running anything.

The pipeline (webapp/financial_markets_builder_manager.py, wrapping vendor/concordia_examples/examples/financial_markets/builder/) has three phases, each a separate button/job-status transition in the UI:

  1. Translate (nl_to_spec.translate): your free-text description is sent to a "coder" LLM (default qwen2.5-coder:32b) which must return a structured ExperimentSpec — not just goods/agents/premise like Economics Lab's market spec, but also a mechanic chosen from seven options (see Parameters) and mechanic-specific parameters. Malformed or invalid output triggers up to max_repairs automatic retries with the validation error fed back (default 3). After translation succeeds, every agent's goal is prefixed with a Census 2011 demographic backstory, same as Economics Lab.
  2. Generate: if mechanic != "custom", this simply renders a ready-to-run Python scenario file (spec_to_scenario.render_scenario_source) from the validated spec and writes it to a generated-experiments directory — no further LLM involvement, status goes straight to "ready". If mechanic == "custom", this instead calls custom_mechanic.generate_extension, which asks the LLM to write a new Python game-master component class implementing whatever bespoke mechanic your description called for, runs it through a static safety scanner, and writes both the new extension file and a companion scenario file that imports it. Status becomes "awaiting_review" and any safety-scanner warnings are surfaced directly in the UI (#safety-warnings) alongside the generated source (#ext-source, #scenario-source) for you to read.
  3. Run: executes the generated scenario. For the custom mechanic, the manager refuses to run unless you've checked "I have read the generated code" (reviewed=True is enforced server-side — run_job raises PermissionError otherwise) — a real gate against blindly executing LLM-generated code, not just a UI nicety. Once run, it uses the same shared_lib.run_scenario machinery as the fixed Financial Markets Scenarios page, producing a metrics report.

A notable engineering detail baked into this pipeline: the coder LLM and the chat/run LLM cannot be loaded into local Ollama VRAM simultaneously without risking an out-of-memory crash (documented as an incident hit during the vendored package's own testing), so the manager holds a process-wide _OLLAMA_LOCK and explicitly unloads whichever model isn't currently needed (_switch_to) before loading the other — meaning only one Financial Markets Builder job (translate/generate/run) can be mid-flight at a time across the whole server, and phase transitions may pause briefly while models swap. When settings.llm_provider == "openrouter" (a hosted API instead of local Ollama), this VRAM concern doesn't apply and an OpenRouter-backed compatibility shim (_OpenRouterOllamaCompat) is used instead, with its own per-job cost budget enforcement.

Parameters

UI controls (webapp/static/financial_markets.html): - Description (#description, textarea): free-text prompt, e.g. "A futures market for solar panels with one manufacturer hedging by selling, a construction company and a speculator buying, settling around round 5." - Max repairs (#max_repairs, number, default 3): how many times nl_to_spec.translate will retry against a validation error before giving up. - Output dir (#output_dir): where run artifacts (structured log, metrics report) get written. - Translate button: kicks off phase 1. - Spec panel: shows the translated spec (with a mechanic badge) once translation succeeds — this is inspect-only in the UI (not a free-edit JSON box like Economics Lab's). - Generate button: kicks off phase 2. - Review panel (only populated/relevant for custom mechanic): shows safety warnings and the full generated extension + scenario source, gated by a "I have read the generated code" checkbox that must be checked before Run becomes available. - Run button: kicks off phase 3, using server-side defaults model_name="llama3.1:8b" and embed_model="embeddinggemma:latest" (not exposed as separate UI fields on this page).

Spec fields (ExperimentSpec, from builder/spec.py), all produced by the LLM during Translate and checked by validate(): - name/display_name/premise: identifying text; name becomes a filesystem-safe slug used for generated filenames. - mechanic: one of clearing_house, fixed_prices, sealed_bid_first_price, sealed_bid_second_price, linked_futures, shock, custom. This is the single most consequential field — it determines both which market-clearing rule applies and which mechanic_params are required. - goods / agents: same shape as Economics Lab's market spec (GoodSpec, AgentSpec with role: producer|consumer), with one hard extra rule: agent names must not look like institutions (a regex checks for words like "Bank", "Fund", "Capital", "Exchange", etc.) — the module docstring explains this was discovered live: naming an agent directly after an institution made the shared memory-initializer game master try to write a childhood biography for a bank, and a live model refused outright. Put the institution in the agent's goal text instead (e.g. "a portfolio manager at Cascade Community Bank"). - max_steps: must be ≥3, and for linked_futures/shock must additionally leave at least 3 rounds of margin (_ROUND_MARGIN) past the mechanic's key round (maturity_round or shock_round) — validated because too-tight timing was found to silently eat the one engine step the initializer handoff always consumes. - mechanic_params (only used by the mechanic that needs them): - sealed_bid_first_price / sealed_bid_second_price: require exactly one producer (the issuer/seller) — SealedBidMarketPlace only reads the first ask order as "the" ask, so extra producers are silently ignored rather than erroring, hence this is enforced up front. - linked_futures: needs futures_good_id (must reference a declared good), spot_price_path (a {round: price} map of an exogenous spot price feed the futures price tracks), and maturity_round (must be a key in spot_price_path). - shock: needs shock_round (≥1), shock_text (what the shock communicates), and informed_agent_names (which agents privately learn about it before it becomes public — must reference real agent names). - custom: needs custom_mechanic_description, a free-text explanation of what should differ from a plain double auction — this is what the code-generation LLM in phase 2 is actually asked to implement. - fixed_prices mechanic additionally requires every good to have both price and inventory set, same as Economics Lab.

Interpreting results

A successful run returns {"metrics_html_path", "metrics_path", "structured_log_path"} — the UI's result panel links directly to the metrics HTML report (#metrics-link, "Open metrics report"). That report is generated by market_metrics.py and computes four metric groups from the underlying MarketPlace state (same computation used by the fixed Financial Markets Scenarios page, described in detail there): price metrics (per-good clearing-price series, total return, volatility as std-dev of round-over-round returns), trade metrics (fill rate, volume, value, VWAP per good), liquidity metrics (best-bid/best-ask spread proxy per round, derived from the recorded supply/demand curves), and agent metrics (final cash/inventory per agent, a Gini coefficient of cash across agents, total cash by role).

Because the mechanic you chose shapes what a "good" outcome even looks like, read the metrics through that lens: a linked_futures spec should show the futures price converging toward the announced spot price as maturity_round approaches (check the price series around that round specifically); a shock spec should show a visible break in the price series at or after shock_round, and — the more interesting question — whether agents not in informed_agent_names traded at stale prices before the shock became public (visible as a burst of favorable fills for informed agents right before the break); a sealed_bid_* auction's single clearing event is best read from trade_history's single resolved price rather than a multi-round series; custom mechanics have whatever bespoke output the generated code produces, which you should sanity-check against the safety-review source you already read.

Gotchas specific to this page: a custom mechanic run is only as trustworthy as the generated code — the static safety scan flags suspicious patterns but is not a correctness guarantee, so treat custom-mechanic results with more skepticism than the fixed mechanics, and re-read #ext-source if a result looks implausible. Translation can fail outright (TranslationError) if the model can't produce a valid spec within max_repairs attempts — this most often happens with under-specified descriptions that don't clearly imply goods/agents/roles, or with descriptions that accidentally suggest an institution-named agent. Because only one builder job runs at a time process-wide (the VRAM lock), a Run you launch while another job is mid-flight elsewhere will sit in "queued (waiting for Ollama host)" rather than starting immediately — this is expected serialization, not a hang.

Finance

Financial Markets Scenarios

Five fixed trading, auction, and shock scenarios

What it is

Financial Markets Scenarios (/financial_markets_scenarios) is the fixed-scenario counterpart to the Builder — five vendored, hand-authored scenarios (vendor/concordia_examples/examples/financial_markets/scenario_0{0..4}_*.py) modeling specific financial-market phenomena: trading behavior, panic selling, and information cascades, exactly as the app's own landing-page copy advertises. Like India Marketplaces, there's no design step — webapp/financial_markets_run_manager.py just dispatches directly to each scenario's run_simulation, and results render as a metrics report rather than raw market state.

The five scenarios, each an NSE/BSE/RBI/MCX/NCDEX-flavored Indian-market analog of a classic market-microstructure situation:

  • 0 — Equity Exchange: a continuous double-auction stock exchange trading two tickers — ZENTA (a growth stock) and URJA (a utility stock) — with three sellers and three buyers of differing valuations. This is the baseline: a clean multi-good, multi-agent double auction, useful as a reference point for how price discovery behaves without any special mechanic layered on.
  • 1 — Government Securities Auction: a sealed-bid, first-price-by-default RBI-style G-Sec auction with one issuer and four institutional bidders of differing size and price sensitivity. Tests sealed-bid price discovery (bidders can't see each other's offers, unlike the open double auction) — the module's own docstring is the origin of the "institution-named agent" validation rule in the Builder (this exact scenario hit a live model refusal when an agent was named after a bank).
  • 2 — Dealer Liquidity: compares price stability in an MCX-style WIDGET market with vs. without a standing, tight-margin dealer persona alongside an opportunistic seller. This is the one scenario explicitly structured as an A/B comparison — the presence of a market-maker willing to always quote a tight bid/ask is the variable being tested for its effect on price stability.
  • 3 — Commodity Futures Hedge: a cash-settled wheat futures market on an NCDEX-style exchange where a Punjab farmer hedges by selling, a flour miller hedges by buying, and a speculator trades on an announced spot index that rises then partially cools before maturity settlement. This exercises the linked_futures mechanic end to end, including its maturity/settlement dynamics.
  • 4 — Correlated Asset Shock: two correlated NSE-style tech stocks (CHIPX, CLOUDX) where one seller privately learns of a CCI (Competition Commission of India, the antitrust regulator) crackdown and dumps inventory before the news is public, while other participants trade without that information. This is the app's canonical information cascade / panic-selling scenario — it exercises the shock mechanic and is the one most directly designed to produce (or fail to produce) a visible informed-trading pattern followed by a public-information price break.

Every scenario module was live-tested against Ollama and the vendor package's docstrings note specific regressions discovered in the process (e.g. scenario 3's docstring documents a live game-master-routing regression that could silently eat a round near maturity) — these are the origin of several ExperimentSpec validation rules in the Builder (the _ROUND_MARGIN requirement in particular).

Parameters

There are exactly three controls (webapp/static/financial_markets_scenarios.html):

  • Scenario (#scenario-cards): choose one of the five fixed scenarios above (populated live from GET /api/financial_markets/scenarios).
  • Max steps (#f-max-steps, number, default 6): overrides the scenario's own default round count. Be cautious overriding this downward on scenarios 3 or 4 — their shock/linked_futures mechanics were authored assuming enough rounds of margin past the key round (maturity/shock) to actually observe the settlement or the post-shock price break; cutting max_steps too close can truncate the very dynamic the scenario exists to show.
  • Backend (#f-backend, select): fake (offline, deterministic — default selection here, unlike the other pages) or ollama (live, via OpenRouter/.env).

As with India Marketplaces, there is no spec-editing surface — goods, agent count, valuations, and mechanic parameters are all fixed in the vendored scenario source. If you want to vary one of these five situations structurally, describe an equivalent premise in the Financial Markets Builder instead.

Interpreting results

Runs use the same shared_lib.run_scenariomarket_metrics.compute_all_metrics pipeline as the Builder, producing a metrics HTML report (linked from the run list once status is "done") built from four metric groups:

  • Price metrics (compute_price_metrics, per good): the round-by-round clearing-price series (prices), plus first/last/min/max, total_return = (last − first) / first, and volatility = population standard deviation of round-over-round simple returns (0.0 if fewer than two valid rounds traded). High volatility with no clear directional total_return suggests a choppy, undiscovered price; low volatility with a large total_return suggests a clean, sustained repricing (which is exactly what scenario 4's shock should produce for the affected stock).
  • Trade metrics (compute_trade_metrics, per good and overall): num_orders vs num_filledfill_rate, total_volume, total_value, and vwap (volume-weighted average price = total_value / total_volume). A low fill_rate signals a market that isn't clearing well — bids and asks aren't crossing, which for scenario 2 (Dealer Liquidity) is precisely the failure mode the standing-dealer variant is meant to prevent.
  • Liquidity metrics (compute_liquidity_metrics, per good): a proxy best-bid/best-ask spread per round, derived from the cumulative supply/demand curves MarketPlace records each round (curve_history) — not the literal best bid/ask, but the discrete-grid approximation from whichever distinct prices were actually submitted that round. Each round entry has best_bid, best_ask, spread (ask − bid; if negative or zero, the book should have crossed and cleared a trade that round), and mid; avg_spread is the mean across rounds with a valid spread. This is the key metric for scenario 2's A/B comparison — a persistently tighter avg_spread in the with-dealer configuration is the expected effect.
  • Agent metrics (compute_agent_metrics): final cash/inventory per agent, cash_gini (a Gini coefficient across agents' ending cash — higher means more concentrated/unequal outcomes), and total_cash_by_role. For scenario 4, check whether the informed seller's final cash position is notably better than an equivalent uninformed trader's — that gap is the information-cascade effect being measured.

For scenario 3 (futures hedge) specifically, cross-reference the futures price series against the scenario's own announced spot-index path around the maturity round — convergence at maturity (futures price ≈ spot price at settlement) is the textbook-correct outcome; a persistent gap suggests the hedgers/speculator didn't arbitrage it away.

Gotchas: the fake backend is the default here and produces deterministic, non-economically-meaningful behavior — useful for confirming a scenario's plumbing (metrics report generation, JSON-safety of NaN/Infinity handling in untraded rounds) works, but don't read anything into fake-backend metrics beyond "the pipeline ran." Also note compute_liquidity_metrics' spread is explicitly a proxy, not the true best bid/ask — it's only as fine-grained as the distinct prices actually submitted that round, so a spread of 0 or a small negative number doesn't necessarily mean "the market was perfectly efficient," just that the grid happened to align that way. As with every other experiment page, a handful of LLM-played agents over 6-ish rounds is a small sample — treat a single scenario run as one draw, not a definitive characterization of the mechanic, especially for the more nuanced comparisons (scenario 2's dealer effect, scenario 4's information-cascade magnitude).

Marketing

Marketing Campaign

Ad targeting, screening, and diffusion of adoption

What it is

The Marketing Campaign simulator (/marketing) is the most economically sophisticated experiment in the app: a multi-round, multi-agent simulation of platform-mediated advertising, built to make two specific, measurable phenomena from the marketing/advertising economics literature emerge from agent behavior rather than being hard-coded:

  1. Information asymmetry in ad allocation (Abhishek, Hosanagar & Barron's work on sponsored search/display allocation under varying verifiability of product quality): when a platform can't fully verify which advertiser is actually higher-quality, it falls back on engagement-optimized delivery — which can systematically favor the wrong advertiser.
  2. The "malaria-ads" targeting gap (the empirical finding that engagement-optimized ad delivery on platforms like Facebook can under-reach exactly the population segments with the highest real need, because those segments are also the most expensive/hardest to reach) — reframed here as a general "does true need correlate with actual reach" question, tunable per product category.

The simulation has two kinds of entities. Companies (2 per product category, always one quality="high" and one quality="low", built on Concordia's stock goal-driven basic.Entity prefab, the same one the signaling example's sellers use) each have a fixed hidden quality, a fixed positioning strategy ("trust", "popularity", or "value" — Cialdini's authority / social-proof / scarcity appeals respectively), and a goal string steering them to maximize their own outcome (genuine adoption for the high-quality company, sales volume regardless of legitimacy for the low-quality one). Consumers (num_consumers of them, using the app's Census-2011-grounded Indian persona sampler) each get a demographic-derived market segment (one of urban_young, urban_senior, rural_young, rural_senior), a hidden need value in [0,1] sampled from that segment's category-specific baseline plus noise, and a Rogers' Diffusion-of-Innovation adopter category (Innovator through Laggard) derived from their existing risk-aversion/conformity traits.

Each round runs a fixed four-phase sequence, driven directly against the already-built Company/Consumer entities (deliberately not using a full Concordia Simulation.play() engine loop or Game Master, because — per the code's own reasoning — there's no multi-entity turn-order negotiation to referee here, just a fixed sequence of direct entity calls):

  1. Bidding. Each company, told its remaining ad budget and the list of segments, is asked to freely allocate a JSON-formatted bid across whichever segments it wants to target this round (e.g. {"bids": {"urban_young": 50.0, "rural_senior": 10.0}}). Bids are clipped to the company's remaining budget if they'd overspend.
  2. Allocation. For each segment, among companies that bid > 0 on it, the platform picks a winner using allocation_score = kappa * quality + (1 - kappa) * reachability — a tunable blend between "give the sponsored slot to the truly better company" (kappa near 1) and "give it to whoever's cheapest/easiest to reach regardless of quality" (kappa near 0). Ties break on higher bid, then randomly.
  3. Exposure and adoption. Every consumer is shown every company's ad this round (sponsored, tagged as such, if that company won their segment; otherwise organic), including the company's positioning-strategy framing and, once any adoptions have accumulated, a social-proof line ("So far, N people have adopted X's product"). Each consumer then makes one AIDA-funnel choice (IGNORED / INTERESTED / DESIRED / ADOPTED) per company, reflecting how far that one ad moved them, based on their private need/personality — this yields one ExposureRecord per (consumer, company) pair per round.
  4. Peer influence (optional). Consumers are randomly paired into dyads; a one-off shared social-encounter premise is generated (deliberately product-agnostic — no forced "talk about the ad" framing), then each pair member gets one free-text turn to say something to the other, naturally mentioning anything they've adopted or skipped if it would plausibly come up — letting word-of-mouth propagate ahead of the next round's ad exposure.

Parameters

The launch form on /marketing exposes:

  • Category (f-category, select, populated from campaigns.CATEGORIES). Ten built-in categories (health, gadgets, clothing_fashion, financial_services, education, agri_inputs, cosmetics_personal_care, fmcg_food, home_services, real_estate), each a distinct pairing of (a) how verifiable quality is before/after purchase — spanning Nelson/Darby-Karni's search vs. experience vs. credence-good spectrum — and (b) whether true need correlates or anti-correlates with how cheap the segment is to reach. For example, health (a public-good bed-net campaign modeled on a real malaria-prevention campaign) has need concentrated in the hardest-to-reach rural_senior segment (need 0.85) versus the easiest-to-reach urban_young (need 0.30) — the classic targeting-gap setup — while gadgets has need and reachability positively correlated (urban_young is both neediest and cheapest to reach), a "neutral" contrast case. Each category ships its own default kappa and budget_per_company, and defines exactly 2 companies (one high-, one low-quality) with category-specific ad creative per segment (including Hindi-English code-switched copy for rural segments).

  • Kappa override (f-kappa, number, step 0.05, range [0,1], blank = use the category's default). This is the single most important experimental dial: it's the information-symmetry level in the allocation formula. kappa=1 means the platform allocates sponsored exposure purely by true quality (best case: a "separating equilibrium" where the sponsored slot reliably signals real quality); kappa=0 means allocation is purely by predicted engagement/reachability, ignoring true quality entirely (a "pooling equilibrium" — the empirically-documented failure mode). Sweeping kappa from 0→1 for the same category, holding everything else fixed, is the intended way to directly observe the platform's screening ability degrade or improve.

  • Consumers (f-consumers, integer, UI range 2–50, default 8). num_consumers in run_experiment — how many Census-grounded personas are sampled as the consumer population, each independently assigned a segment/need/adopter-category. More consumers gives smoother, less noisy aggregate metrics (targeting gap, adoption rates) at the cost of more LLM calls per round (every consumer reacts to every company's ad every round). Capped by the size of the app's name pool.

  • Rounds (f-rounds, integer, UI range 1–20, default 3). num_rounds — how many bid/allocate/expose/peer-influence cycles run. More rounds lets the diffusion-of-innovation adoption curve actually develop an S-shape and lets word-of-mouth compound (adoption is cumulative across the whole run, never reset per round), but again scales total LLM call volume roughly linearly.

  • Backend (f-backend, select: fake or ollama, labeled "live (OpenRouter, via .env)" in this experiment specifically — marketing routes live traffic through OpenRouter rather than a local Ollama host used elsewhere in the app).

  • Seed (f-seed, integer, default 1). Drives both the consumer-population sampling (build_consumers) and all allocation tie-breaking/pair-selection randomness (random.Random(seed) inside run_experiment) — a fixed seed with fixed num_consumers reproduces the same population and the same random tie-breaks/pairings run to run.

  • Word-of-mouth peer influence (f-peer checkbox, default checked). Maps to enable_peer_influence. Unchecking it skips phase 4 entirely each round — useful for isolating how much of the adoption pattern is driven by advertising alone versus social spread, by comparing otherwise-identical runs with this toggled on and off.

  • Start paused (f-start-paused checkbox, default checked). Same semantics as in Social Network — controls the run's initial StepController state; play/pause/step/stop controls are always available afterward.

  • num_companies exists as a parameter on run_experiment (use only the first N of the category's companies) but is not exposed in the UI form — every category ships exactly 2 companies, and the web form always uses both.

Interpreting results

The results view aggregates each round's ExposureRecords (one per consumer-company pairing) into several purpose-built metrics, all in socialnet2.marketing.scoring:

  • Targeting gap (targeting_gap) — the Pearson correlation, across all consumers, between a consumer's hidden true need and how many times they were actually reached by any ad. A positive gap means the platform is (emergently) finding the people who need the product most; a gap near zero or negative means reach is happening independent of, or even in spite of, true need — the exact malaria-ads-paper failure mode, where engagement-optimized delivery skips the highest-need, hardest-to-reach segment. This metric is most informative as a function of kappa: run the same category at kappa=0.1 and kappa=0.9 and compare. Note it returns exactly 0.0 (not "undefined" or NaN) if there are fewer than 2 distinct consumers or if either the need or reach-count series has zero variance — don't mistake a flat 0.0 for "no gap" when it might just mean too small/too uniform a sample.

  • Screening efficacy (screening_efficacy) — returned as {"high": lift, "low": lift}, where lift = (sponsored-slot adoption rate) − (organic adoption rate), computed separately for high-quality and low-quality companies. A well-screening platform (high kappa) should show a positive lift for the high-quality company (the sponsored slot genuinely helps it convert better than it would organically) and a near-zero or negative lift for the low-quality company (the sponsored slot isn't rescuing a fundamentally weak product). A poorly-screening platform (low kappa) tends to blur or invert this pattern — e.g. the low-quality company getting just as much or more lift from being sponsored. A quality tier is omitted from the result entirely if it has zero sponsored or zero organic records that round/run — a common occurrence with small num_consumers and num_rounds, so don't read a missing key as "zero lift," it means "not enough data this run."

  • Funnel drop-off (AIDA) (funnel_dropoff) — for each of IGNORED → INTERESTED → DESIRED → ADOPTED, the count of exposures that reached at least that stage (so it's a monotonically non-increasing bar chart, not per-stage-exclusive counts). This is your view into how an ad campaign is failing or succeeding at each step of the classic attention→interest→desire→action funnel, independent of which company or segment is involved.

  • Adoption curve (adoption_curve) — a list of (round, cumulative_adopted_count) pairs, one entry per round (including rounds with zero new adoptions, so no round is silently skipped in a chart). This is the empirical Diffusion-of-Innovation S-curve; a healthy diffusion pattern shows slow early growth (Innovators), an accelerating middle (Early/Late Majority), and a flattening tail (Laggards) — check this against adoption rate by adopter category to see whether that shape is actually being driven by the right personas adopting early.

  • Adoption rate by adopter category (adoption_rate_by_adopter_category) — fraction of exposures that converted, split by each consumer's Rogers category (Innovator, Early Adopter, Early Majority, Late Majority, Laggard). Use this to sanity-check that the simulation's emergent adoption pattern is actually skewing toward Innovators/Early Adopters converting more readily than Laggards, rather than assuming the labels alone guarantee that behavior — they're derived from personality traits (risk-aversion, conformity) feeding into the agent's persona, not hard-coded adoption probabilities, so it's a genuine (if noisy, especially at low num_consumers) emergent check.

  • Reach by segment / adoption rate by segment (reach_by_segment, adoption_rate_by_segment) — raw exposure counts and conversion rates per market segment; useful for spotting which of the four segments (urban/rural × young/senior) is systematically under- or over-served this run, complementing the single-number targeting_gap.

  • Bids and winners are shown per round — worth checking directly if a metric looks off: e.g. if a company consistently bids $0 on a high-need segment (a live-model bidding failure, or the model rationally chasing reach instead), that segment gets no sponsored winner that round (still gets organic exposure from both companies) and will show up as an allocation gap regardless of what kappa says should happen.

A structural gotcha: because remaining_budget is never reset within a run (spend carries across rounds) but cumulative_adoptions is also never reset (adoption is a running total by design), a company that overspends or gets consistently out-bid in early rounds can become budget-constrained for the rest of the run — check the per-round bid/budget numbers, not just the final adoption tallies, if a company's adoption curve flatlines partway through a multi-round run. And as with Social Network, num_consumers in the range the UI allows (2–50) is still small relative to a real market — treat targeting_gap and screening_efficacy as illustrative, qualitative signals of mechanism (does this kappa/category combination tend toward good or bad screening), not as precise point estimates; rerun with different seeds before trusting any single run's exact numbers.

Social

Social Network

An open-ended forum with no scripted storyline

What it is

The Social Network experiment (/socialnetwork) simulates a general-interest online discussion forum — modeled explicitly as "Desi Town Square," a community forum "used by people across India" — over a short, fictional two-day window. It answers a simple but consequential question for anyone studying social platforms: when you seed a forum with a population of psychologically distinct people and let them act autonomously (post, reply, upvote, downvote) with no scripted storyline, what conversational and engagement patterns emerge? Do certain personas dominate the feed? Does voting concentrate on a few posts? Do reply threads form organically around specific topics?

Unlike most of the other experiments in this app (Asch, Collectivism, Ultimatum, etc.), which use bespoke, hand-written Game Masters to referee a structured task, Social Network is a thin wrapper around Concordia's own out-of-the-box async_social_media.GameMaster prefab (registered in this codebase as "social_media_gm"), combined with the existing Census-grounded PsychAgent entity prefab. No custom orchestration logic was needed because Concordia's stock forum machinery already does everything required: it manages a shared "Forum" data structure, gives each agent a per-turn observation of what's new since they last checked in, and parses/executes whatever action each agent decides to take.

Concretely, a run consists of:

  1. Population sampling. n_agents personas are drawn using the same Census-2011-grounded sampling method used throughout the app (psych_profile.sample_profile), each with a distinct Big Five personality, cognitive traits (risk-aversion, conformity, etc.), and demographic profile (age, urban/rural, etc.). Each gets a name from the app's fixed name pool (socialnet2.names.NAMES) and an independently-derived random seed, so personas differ from run to run only via the seed, not by re-running the same sampling draw four times.

  2. Forum setup. A ForumState object is created — a thread-safe, in-memory Reddit-style bulletin board (concordia.contrib.components.game_master.forum.ForumState) tracking posts, nested replies, upvote/downvote counts, and a per-player "unread" notification queue. This is wrapped by two supporting components: ForumResolution, which parses each agent's raw JSON action text ({"action": "post", ...}, {"action": "reply", "post_id": ..., ...}, {"action": "upvote", "post_id": ...}, etc.) and applies it directly to ForumState with zero additional LLM calls; and ForumObservation, which — before each agent's turn — drains that agent's queued notifications (e.g. "your post got upvoted") and computes a summary of everything new since their last visit (new posts, new replies to posts they've seen, vote-count snapshot).

  3. The simulation loop. Because async_social_media.GameMaster's scheduling component returns every eligible agent at once as a candidate for the round (a participation filter, representing "who's currently active on the forum," not a strict turn order), the run uses Concordia's Simultaneous engine rather than the Sequential engine most other experiments in this app use. Each round, every eligible agent independently decides — based on its persona, its accumulated memory, and what it currently observes on the forum — whether and how to act: create a new post, reply to an existing one, upvote, or downvote. There's no explicit storyline forcing agents toward any topic; whatever they post about (local news, daily life, questions, opinions) is entirely emergent from their persona and the open-ended premise.

  4. Wrap-up. Once the run finishes (or is stopped), the full interaction log and the final forum state (every post, every reply, every vote) are flushed to the app's SQLite database for later inspection under "past runs."

Parameters

The launch form on /socialnetwork exposes:

  • Number of agents (f-n-agents, integer, UI range 1–20, default 5). This is n_agents in socialnetwork.orchestration.build_config. It sets how many distinct personas populate the forum. More agents means more concurrent posting/replying/voting activity per round (since the engine is Simultaneous, every eligible agent acts every round), a livelier and more varied feed, but also proportionally more LLM calls per round when using a live backend — so wall-clock time per round scales roughly linearly with this number. The hard ceiling is the size of the app's name pool (names_lib.NAMES); requesting more agents than there are names raises a ValueError.

  • Max steps (f-max-steps, integer, optional — blank means "auto"). Maps to max_steps in build_config. If left blank, it's computed by default_max_steps(n_agents) = max(1, n_agents) * STEPS_PER_AGENT_PER_DAY * N_DAYS, where STEPS_PER_AGENT_PER_DAY = 2 and N_DAYS = 2 — i.e. by default each agent gets roughly 4 turns worth of "budget" spread across the run, calibrated (per the code's own comment) to feel like "a couple of days of light forum use," not literal wall-clock pacing. Setting this explicitly overrides that formula; a higher value means more rounds, hence more total posts/replies/votes possible, at the cost of more LLM calls.

  • Backend (f-backend, select: fake or ollama). fake uses FakeLanguageModel with a canned deterministic response function and a fake embedder — useful for testing the UI/plumbing instantly with zero real generation, but the "conversation" it produces is not meaningful content. ollama routes through a socialnetwork-specific Ollama host/model (settings.socialnetwork_ollama_host / settings.socialnetwork_ollama_model) for chat completions, while embeddings (used for each agent's associative memory retrieval) are drawn from the main Ollama host, because the socialnetwork-specific host has no embedding model pulled and 501s on /api/embed. This split is a deployment detail, not something you configure per run — but it explains why a socialnetwork run's chat "voice" can differ from other experiments' even when both say "live."

  • Seed (f-seed, integer, default 1). Drives np.random.default_rng(seed), from which every agent's individual persona-sampling seed is derived (rng.integers(...), one per agent). Changing the seed resamples an entirely different population — different personalities, different demographics — while holding n_agents fixed. The same seed with the same n_agents reproduces the same population (though not necessarily the same forum outcome, since agent decisions still depend on live model sampling unless you're on the deterministic fake backend).

  • Start paused (f-start-paused checkbox, default checked). Controls whether the run's StepController begins in a paused state. If checked, the run is created but sits idle until you press play, letting you inspect the just-sampled population before committing LLM calls; if unchecked, it starts running immediately. Independent of this initial state, every run exposes play/pause/step/stop controls throughout its lifetime.

There is no separate "forum name" or "premise" field in the UI — those are fixed in code (DEFAULT_FORUM_NAME = "Desi Town Square", DEFAULT_PREMISE), though the underlying build_config function does accept forum_name and premise overrides if you're driving it programmatically (e.g. from a script) rather than through the web form.

Interpreting results

The run view surfaces, live, as the simulation proceeds:

  • Posts feed (state.posts), each with post_id, author, title, content, timestamp, votes, and a replies list (each reply carrying author, content, timestamp). This is polled from the live ForumState roughly every 2 seconds independent of the per-round step callback — the code deliberately does this because with Simultaneous, a full round (every agent's turn) can take minutes when several agents each need a live model call, but individual posts/replies/votes land the moment that one agent's action resolves. Without the separate poll, the feed would appear to update only in one big batch per round instead of filling in incrementally; if you're watching a live run, expect posts to appear one at a time, not all at once at "round boundaries."

  • n_posts / n_replies — simple totals derived from the posts list; useful at a glance for "how much happened" without reading every entry.

  • Steps log (state.steps), one entry per Concordia step: step (index), acting_entity, action (the raw text of what that entity did). This is the closest thing to a full transcript and is the right place to look if a post/reply/vote seems to have not "stuck" — cross-reference the acting entity's raw action against what appears (or doesn't) in the posts feed. A vote or reply against a nonexistent post_id fails gracefully (the forum responds with an error message and notifies the acting agent — see ForumState.parse_and_execute_action), rather than crashing the run, so a step can "succeed" in the log while doing nothing observable to the forum.

  • Votes. Every post tracks a running votes integer (can go negative). Because there's no vote-fraud or one-vote-per-user enforcement modeled explicitly beyond what the agents themselves choose to do, and because the population is small (typically single digits of agents), don't over-read a vote swing of 1–2 as a meaningful "consensus" signal — with N this small, a single opinionated persona (or an LLM's known tendency toward some response patterns) can dominate the vote tally. This is a qualitative, small-N conversational simulation, not a statistically powered survey instrument; use it to explore what kinds of interactions emerge (does a particular persona's post get argued with? does an early post accumulate replies while later ones don't?) rather than to draw quantitative conclusions from vote counts or post counts alone.

  • Status field (starting / running / paused / done / error). If a run shows error, the error field carries the full exception and traceback — the most common live-backend failure mode is a malformed JSON action from the model (see ForumState.parse_and_execute_action's explicit "Could not parse action" error path), which surfaces as a failed action in the steps log rather than crashing the whole run — an error status usually indicates something structural (model unreachable, embedder failure) rather than a single bad action.

  • Persisted runs. Once a run reaches done, the full simulation log and final forum snapshot are flushed to SQLite (flush_lib.flush_run plus social_instrumentation.flush_forum), so you can revisit posts/replies/votes later from the "past runs" list without re-running anything. The persisted view is a faithful snapshot of the live view's final state — nothing is recomputed or re-aggregated after the fact — so the numbers you see when reviewing history are exactly what the run ended with.

One gotcha worth calling out explicitly: because every eligible agent acts every round (this is a "participation filter," not a fixed turn order), the effective amount of forum activity per round scales with n_agents, but the total number of rounds (max_steps) does not automatically scale down to compensate — if you raise n_agents without also considering max_steps, you get proportionally more per-round activity and the same number of rounds, so total posts/replies/votes for the whole run grow faster than n_agents alone would suggest.

Demo

Scenario 0: Robot Alchemy

A fixed, four-character demo / smoke-test forum

What it is

Scenario 0 (/scenario00, internally "Social Media: Robot Alchemy") is not one of this app's original research experiments — it's a vendored, unmodified upstream Concordia example (see vendor/concordia_examples/NOTICE.md), specifically examples/social_media/scenario_00_robo_alchemy.py, wired into the webapp purely as a fast, deterministic-population smoke-test / demo scenario. The webapp's own comments are explicit about its purpose: "Designed for rapid iteration during development, also sometimes produces amusing results." If you want to sanity-check that the forum machinery, live-model backend, and step-by-step visualization are all working correctly — without waiting on Census-based persona sampling or spinning up any of the app's bespoke orchestration — this is the fastest path to a working, watchable run.

The premise: four fixed (not randomly sampled), richly hand-written personas share a themed hobbyist forum, "The Robotic Athanor Forum," devoted to "robot-assisted experimentation with medieval alchemy" — build logs, alchemical-theory debates, manuscript analysis, and a buy/sell/trade section, all nominally set in the SF/Bay Area in 2026. The four fixed characters are deliberately built for maximal conversational friction:

  • Silas Varnham (34, robotics engineer, Mission District) — believes the medieval four-element theory is literally correct and the Philosopher's Stone is synthesizable with sufficiently precise robotic control.
  • Petra Ouyang (29, AI researcher, SoMa) — believes the Philosopher's Stone is a metaphor for iterative optimization, not a literal substance; is suspicious that a mysterious user "Paracelsus_Rex" might be a sock puppet of Thaddeus.
  • Diego Esparza (41, glassblower/maker, Outer Sunset) — a terse pragmatist who cares about craftsmanship over theory, uses the downvote button liberally, but secretly (and reluctantly) admires "Paracelsus_Rex"'s theatrical flair.
  • Thaddeus "Aurelius" Thorne (55, self-styled "Knight of the Hermetic Order") — writes in archaic, theatrical prose, despises automatons and roboticists, and — as revealed to the reader (though not necessarily surfaced to the other agents) — secretly posts under the "Paracelsus_Rex" persona to troll digital-alchemy forums.

Structurally, this is architecturally almost identical to the Social Network experiment: it uses the same stock async_social_media.GameMaster prefab (registered here as async_social_media__GameMaster, instance-named forum_rules) wrapping the same ForumState/ForumResolution/ForumObservation machinery, and the same Simultaneous-style participation-filter scheduling. Where it differs is entity construction and initialization: instead of PsychAgent (this app's Census-grounded, Big-Five-plus-cognitive-parameters persona) built by socialnet2.orchestration, it uses Concordia's plain stock basic__Entity prefab, and instead of algorithmic profile sampling, each character's entire personality is seeded via hand-authored "formative memories" injected by a formative_memories_initializer__GameMaster INITIALIZER role that runs once before the forum GM takes over — a list of biographical/belief/personality-defining statements per character (quoted verbatim above), plus shared_memories describing the forum itself. This initializer pattern (build up an entity's persona via explicit memory injection rather than a psychometric sampler) is characteristic of how "vanilla" Concordia examples construct characters, in contrast to this app's own PsychAgent-based experiments.

The webapp's run manager (webapp/scenario00_run_manager.py) is a near-mechanical mirror of socialnetwork_run_manager.py: same background-thread-plus-StepController pattern, same 2-second ForumState polling loop so posts appear incrementally rather than only at round boundaries, same per-step callback recording (step, acting_entity, action) tuples. The one architectural difference is that it doesn't build a Config/Simulation itself — it calls straight into the vendored example's own run_debug_simulation(...) function, which already accepts the same step_controller/step_callback interface the rest of this app's managers use, plus a simulation_callback hook the webapp uses solely to capture a reference to the live Simulation object (so it can pull the ForumState out of the game master afterward) — a deliberate design choice so the vendored upstream code needs zero modification to plug into this app's UI.

Parameters

The launch form on /scenario00 exposes only two run-level settings — deliberately minimal, since the scenario's cast, premise, and forum topic are all fixed in the vendored code and not meant to be configured:

  • Max steps (f-max-steps, integer, default 8, min 1). Maps directly to the max_steps argument of run_debug_simulation, whose own docstring calls it "Number of player steps to run. Defaults to 8." Because all four characters act simultaneously each round (same participation-filter scheduling as Social Network), 8 steps is enough for a handful of posts and a couple of reply exchanges per character in a short demo run; raising it lets the conversation develop further (more replies, more chances for the Paracelsus_Rex thread to surface) at a proportional cost in LLM calls when using a live backend.

  • Backend (f-backend, select: fake or ollama). fake again uses the deterministic FakeLanguageModel/fake embedder pair — fine for verifying the run mechanics and UI wiring instantly, but it won't produce anything resembling the characters' actual voices (Thaddeus's mock-archaic prose, Diego's terseness, etc.), since those only emerge from a real model conditioning on the injected formative memories. ollama reuses the same socialnetwork-specific Ollama host/model override the Social Network experiment uses (settings.socialnetwork_ollama_host / settings.socialnetwork_ollama_model) — the code comments note this is "faster, but less reliable at producing valid JSON than the main host's" larger model, a tradeoff already accepted for this fast-iteration scenario specifically.

  • Start paused (f-start-paused checkbox, default checked). Same StepController-initial-state semantics as the other live-run experiments.

There is no agent-count, seed, or premise field, because — unlike every other experiment in this app — this scenario's population isn't sampled at all: it's the same fixed four named characters with the same fixed formative memories every single time you launch it. The only source of run-to-run variation is the language model's own sampling stochasticity (on the ollama backend) or, on fake, effectively none.

Interpreting results

The live/results view is structurally identical to Social Network's, since both draw from the same ForumState:

  • Posts feed, each with author, title, content, timestamp, vote count, and nested replies — polled every 2 seconds independently of the step callback, for the same reason as Social Network (a full simultaneous round can take a while with a live model, but individual actions land as soon as that one character's turn resolves).
  • n_posts / n_replies running totals.
  • Steps log — one entry per step with acting_entity and the raw action text, useful for seeing exactly what each of the four characters attempted versus what actually landed on the forum (a malformed action fails gracefully with an error message rather than crashing the run, per ForumState.parse_and_execute_action).
  • Status (starting/running/paused/done/error), with full traceback in error if something breaks structurally (unreachable model, embedder failure).

Because this is fundamentally a demo/smoke-test scenario rather than a designed experiment with a measurable hypothesis, there are no scoring functions, aggregate metrics, or "correct" outcome to check results against — unlike Marketing's targeting-gap/screening-efficacy metrics or Asch's conformity-rate scoring, Scenario 0 has nothing analogous in the codebase, and none is computed by the run manager. The intended way to "interpret results" here is qualitative and almost literary: does Thaddeus's Paracelsus_Rex secret ever leak into the visible conversation (e.g. does Petra's suspicion get voiced, and if so, does anyone confirm or deny it)? Does Diego actually downvote things, matching his written personality? Do the four characters' fixed belief conflicts (four-element theory vs. optimization-metaphor vs. craftsman-pragmatism vs. anti-automation zealotry) surface as visible disagreement in the thread, or does the model flatten them into blander agreement? Because the cast and their backstories are fixed, this scenario is best used comparatively across repeated live runs (same characters, same premise, different model sampling) to get a feel for how faithfully a given backend model sustains distinct, conflicting personas over a short multi-turn forum conversation — which is exactly the "rapid iteration during development" use case the vendored example was built for, not for drawing any research conclusion.

One practical gotcha: since max_steps defaults to only 8 and all four characters act each round, a short run may end before any of the more interesting long-tail behaviors (the Paracelsus_Rex reveal, a full reply thread with rebuttals) have a chance to develop — if a run feels anticlimactic, raising max_steps before concluding "the model isn't sustaining the personas" is the first thing to try.