# Project export: Palma Housing Equilibrium: Autonomous Agentic Governance

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: A Multi-Agent System where Fetch.ai Regulators and Visa Settlement Agents autonomously solve housing displacement in real-time.
- Devpost: https://devpost.com/software/palma-housing-equilibrium-autonomous-agentic-governance
- GitHub: https://github.com/PauRoca06/Palma-Housing-Equilibrium-Simulation
- Video: https://www.youtube.com/embed/UPJ9H14UQYQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

I was born and raised in Palma de Mallorca, a city that is currently facing an existential threat. To the world, it is a vacation paradise; to locals, it is a closed door. With rents rising 19% year-over-year and local wages stagnating at ~€23,000 (12% below the Spanish average), over 40% of the local workforce is now priced out of their own city. Traditional policy is too slow—by the time a subsidy is approved, families are already evicted. With this project I wanted to ask: What if city governance was autonomous and worked real-time?

### What it does

The project tries to be a Closed-Loop Economic Digital Twin of Palma that doesn't just simulate the crisis but solves it autonomously. Simulates: A Walrasian Auction engine models the interaction between 1,100 synthetic agents (Locals vs. Expats) and 1,100 housing units across 5 real neighborhoods, anchored in 2026 INE (Spanish National Statistics Institute) wage/rent benchmarks. Simulates: A Walrasian Auction engine models the interaction between 1,100 synthetic agents (Locals vs. Expats) and 1,100 housing units across 5 real neighborhoods, anchored in 2026 INE (Spanish National Statistics Institute) wage/rent benchmarks. Monitors: A Fetch.ai Regulator Agent runs infinitely in the background, auditing the market state every 5 seconds to simulate a real societal market. Monitors: A Fetch.ai Regulator Agent runs infinitely in the background, auditing the market state every 5 seconds to simulate a real societal market. Acts: If the families displaced hits a critical threshold (e.g., >300 families), the Regulator triggers a Visa Settlement Agent to inject targeted liquidity (vouchers) via a simulated payment rail to affected families. Acts: If the families displaced hits a critical threshold (e.g., >300 families), the Regulator triggers a Visa Settlement Agent to inject targeted liquidity (vouchers) via a simulated payment rail to affected families. Heals: The system creates a feedback loop where agent actions actively stabilize market prices in real-time, creating a dynamic equilibrium. Heals: The system creates a feedback loop where agent actions actively stabilize market prices in real-time, creating a dynamic equilibrium. How I built it We architected a Multi-Agent System (MAS) where economic theory meets decentralized infrastructure. 1. The Math: Walrasian Tâtonnement At the core of my simulation is a solver that iteratively finds the market-clearing price vector $p$ for a set of zones $Z$. Each agent $i$ maximizes their utility function: $$ U_i(z) = \alpha_i \cdot \text{Pref}_{i,z} - \beta_i \left( \frac{p_z \cdot \text{BasePrice}_z}{\text{Income}_i + \text{Subsidy}} \right) $$ Where $\alpha$ and $\beta$ represent the trade-off between location preference and budget constraints. 2. The Agency: Fetch.ai uAgents I deployed two different identities as agents: The Regulator: An autonomous auditor that reads the simulation state. The Bank (Visa): A settlement agent that executes transactions. They perform a cryptographic handshake to authorize funds without human intervention. 3. The Visuals: Streamlit & PyDeck I built a 3D Geospatial Command Center that visualizes Effective Rent (what locals pay) vs. Market Rent. The map updates dynamically, showing "Red Zones" turning "Cyan" as the agents successfully intervene. Challenges I ran into The Inflationary Trap: Initially, injecting vouchers caused "Landlord Agents" to raise prices, negating aid. I had to implement "Market Entropy" and "Agent Stability Bonuses" to simulate a realistic tug-of-war between inflation and social liquidity. Python 3.14/Pydantic V1 Conflict: The uagents framework faced type-evaluation issues with the newest Python 3.14 release. I resolved this by architecting a version-pinned, optimized Python 3.12 virtual environment. Asynchronous Synchronization: Ensuring a Streamlit frontend could reflect real-time transactions occurring between two independent Fetch.ai agents required a custom JSON-based "Self-Healing Handshake" protocol. Accomplishments that I am proud of Closed-Loop MAS: Achieving a system where agent decisions mathematically alter the "physics" of the economic simulation in real-time. The "Wobble": Successfully modeling market volatility, where displacement numbers realistically fluctuate based on landlord greed and external economic shocks. 100% Autonomous Settlement: Building a verifiable ledger where every subsidy batch is cryptographically authorized by the Regulator. What I learned I learned about dynamic equilibrium, making AI agents interact in a real world situation and the importance of high-frequency intervention in crisis.

### What's next

Deploying the Visa Agent on Modal for serverless scaling and integrating real Fetch.ai Wallet transactions to allow citizens to receive actual stablecoin subsidies directly to their mobile devices. Extending the map to the whole Mallorca island and for global capabilities.

## README (from the GitHub repository)

# CS109 Stochastic Housing Equilibrium


## Detected evidence (automated analysis)

Indexed codebase: 6 recognized source files, 16 KB.
- Python (language) — detected in the code
- Streamlit (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (12 of 12)

```
.gitignore
agent_thoughts.json
dashboard.py
data_gen.py
population.json
README.md
regulator_agent.py
results.json
solver.py
visa_address.txt
visa_agent.py
visa_ledger.json
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- Initial CS109 Probability Challenge Submission
- Fix: Decouple slider from simulation trigger for cleaner demo
- TreeHacks 2026: Initial MVP with Fetch.ai Agents and Visa Settlement

## Key source files (fetched from GitHub, selected and truncated for size)

### data_gen.py

```python
import numpy as np
import json

def generate_palma_data(num_tenants=1000, num_units=1100):
    neighborhoods = {
        "Son Vida": {"lat": 39.598, "lon": 2.601, "price_m2": 24.50}, 
        "Old Town": {"lat": 39.571, "lon": 2.648, "price_m2": 21.80},
        "Portixol": {"lat": 39.558, "lon": 2.685, "price_m2": 20.17},
        "Llevant": {"lat": 39.575, "lon": 2.685, "price_m2": 17.04},
        "Suburbs": {"lat": 39.615, "lon": 2.645, "price_m2": 15.20}
    }

    tenants = []
    for i in range(num_tenants):
        is_expat = np.random.rand() < 0.35 
        income = np.random.lognormal(mean=11.2 if is_expat else 10.15, sigma=0.5 if is_expat else 0.35)
        tenants.append({
            "id": f"tenant_{i}", "type": "Expat" if is_expat else "Local",
            "income": round(income, 2), "monthly_budget": round((income / 12) * 0.45, 2)
        })

    units = []
    for i in range(num_units):
        zone = np.random.choice(list(neighborhoods.keys()), p=[0.1, 0.2, 0.2, 0.3, 0.2])
        n = neighborhoods[zone]
        
        while True:
            # INCREASED JITTER: spreads dots so color changes are visible
            lat = n["lat"] + np.random.normal(0, 0.005) 
            lon = n["lon"] + np.random.normal(0, 0.005)
            if not (lat < 39.563 and lon < 2.665): break 

        units.append({
            "id": f"unit_{i}", "zone": zone, "lat": lat, "lon": lon,
            "market_price": round(max(35, np.random.normal(90, 20)) * n["price_m2"], 2)
        })

    with open("population.json", "w") as f:
        json.dump({"tenants": tenants, "units": units}, f, indent=2)
    print("✅ 2026 Socio-Economic Data Generated.")

if __name__ == "__main__":
    generate_palma_data()
```

### regulator_agent.py

```python
import json
import os
import time
from datetime import datetime
from uagents import Agent, Context, Model

class VoucherPayment(Model):
    resident_id: str
    amount: float

regulator = Agent(
    name="palma_regulator", 
    seed="regulator_seed_fixed_2026_v2", 
    port=8001, 
    endpoint=["http://127.0.0.1:8001/submit"]
)

# HELPER: Get the absolute path to the project folder
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
HANDSHAKE_FILE = os.path.join(PROJECT_DIR, "visa_address.txt")
RESULTS_FILE = os.path.join(PROJECT_DIR, "results.json")
THOUGHTS_FILE = os.path.join(PROJECT_DIR, "agent_thoughts.json")

def log_thought(thought):
    try:
        thoughts = []
        if os.path.exists(THOUGHTS_FILE):
            with open(THOUGHTS_FILE, "r") as f: thoughts = json.load(f)
        thoughts.insert(0, {"timestamp": datetime.now().strftime("%H:%M:%S"), "agent": "Regulator", "thought": thought})
        with open(THOUGHTS_FILE, "w") as f: json.dump(thoughts[:15], f, indent=2)
    except: pass

@regulator.on_interval(period=5.0)
async def check_market(ctx: Context):
    # 1. FIND THE VISA AGENT (Absolute Path)
    if not os.path.exists(HANDSHAKE_FILE):
        print(f"⚠️ Waiting for file at: {HANDSHAKE_FILE}")
        log_thought("Searching for Visa Agent signal...")
        return
    
    with open(HANDSHAKE_FILE, "r") as f:
        visa_address = f.read().strip()

    # 2. CHECK MARKET DATA
    if os.path.exists(RESULTS_FILE):
        with open(RESULTS_FILE, "r") as f:
            try:
                data = json.load(f)
                priced_out = data.get("priced_out", 0)
            except: priced_out = 0
            
        if priced_out > 300:
            log_thought(f"🚨 ALERT: {priced_out} families displaced. Authorizing funds to {visa_address[-6:]}...")
            try:
                await ctx.send(visa_address, VoucherPayment(resident_id="BATCH_LOCAL", amount=300.0))
            except Exception as e:
                print(f"Send Error: {e}")
                log_thought(f"Connection Error: {str(e)}")
        else:
            log_thought(f"Market Stable ({priced_out} displaced). Monitoring.")

if __name__ == "__main__":
    regulator.run()
```

### visa_agent.py

```python
import json
import os
import sys
from datetime import datetime
from uagents import Agent, Context, Model

class VoucherPayment(Model):
    resident_id: str
    amount: float

# Fixed seed for consistent address
visa_agent = Agent(
    name="visa_settlement", 
    seed="visa_seed_fixed_2026_v2", 
    port=8000, 
    endpoint=["http://127.0.0.1:8000/submit"]
)

# Helper for absolute paths
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
HANDSHAKE_FILE = os.path.join(PROJECT_DIR, "visa_address.txt")
LEDGER_FILE = os.path.join(PROJECT_DIR, "visa_ledger.json")
THOUGHTS_FILE = os.path.join(PROJECT_DIR, "agent_thoughts.json")

def log_thought(thought):
    try:
        thoughts = []
        if os.path.exists(THOUGHTS_FILE):
            with open(THOUGHTS_FILE, "r") as f: thoughts = json.load(f)
        thoughts.insert(0, {"timestamp": datetime.now().strftime("%H:%M:%S"), "agent": "Visa Agent", "thought": thought})
        with open(THOUGHTS_FILE, "w") as f: json.dump(thoughts[:15], f, indent=2)
    except: pass

@visa_agent.on_event("startup")
async def startup(ctx: Context):
    # Initial Write
    with open(HANDSHAKE_FILE, "w") as f:
        f.write(ctx.agent.address)
    
    print(f"\n✅ VISA ONLINE.")
    print(f"📍 Handshake saved to: {HANDSHAKE_FILE}")
    print(f"🔑 Address: {ctx.agent.address}\n")
    log_thought("System Online. Handshake broadcast.")

# --- ♻️ NEW: SELF-HEALING CONNECTION ---
@visa_agent.on_interval(period=3.0)
async def maintain_handshake(ctx: Context):
    # If the dashboard reset button deleted the file, recreate it instantly!
    if not os.path.exists(HANDSHAKE_FILE):
        with open(HANDSHAKE_FILE, "w") as f:
            f.write(ctx.agent.address)
        ctx.logger.info("♻️ Connection restored (Handshake file recreated).")

@visa_agent.on_message(model=VoucherPayment)
async def handle_payment(ctx: Context, sender: str, msg: VoucherPayment):
    timestamp = datetime.now().strftime("%H:%M:%S")
    txn_id = f"VISA-{timestamp.replace(':','')}-{ctx.agent.address[-4:]}"
    
    log_thought(f"⚡ Voucher Received. Settling Batch {txn_id}...")

    new_record = {
        "Transaction ID": txn_id, 
        "Agent ID": f"Regulator_Auth", 
        "Status": "SETTLED (Confirmed)", 
        "Amount": f"€{msg.amount}"
    }
    
    history = []
    if os.path.exists(LEDGER_FILE):
        with open(LEDGER_FILE, "r") as f:
            try: history = json.load(f)
            except: history = []
    
    history.insert(0, new_record)
    with open(LEDGER_FILE, "w") as f: json.dump(history, f, indent=2)

if __name__ == "__main__":
    visa_agent.run()
```

### solver.py

```python
import json
import numpy as np
import copy
import os

def run_auction(expat_tax_multiplier=1.0, subsidy_amount=0, write_to_disk=True):
    try:
        with open("population.json", "r") as f: 
            data = json.load(f)
    except FileNotFoundError:
        return None

    # --- 🤖 AGENT FEEDBACK LOOP ---
    agent_impact_bonus = 0
    ledger_file = "visa_ledger.json"
    settlement_count = 0
    
    if os.path.exists(ledger_file):
        try:
            with open(ledger_file, "r") as f:
                ledger = json.load(f)
                settlement_count = len(ledger)
                agent_impact_bonus = settlement_count * 4.5 
        except:
            agent_impact_bonus = 0

    tenants = copy.deepcopy(data["tenants"])
    units = copy.deepcopy(data["units"])
    zones = list(set(u["zone"] for u in units))
    prices = {z: 1.0 for z in zones}
    
    # CS109: Market Volatility (Uniform Random Variable)
    volatility = np.random.uniform(0.99, 1.01)
    
    for iteration in range(150):
        demand, supply, priced_out = {z: 0 for z in zones}, {z: 0 for z in zones}, 0
        for u in units: supply[u["zone"]] += 1

        for t in tenants:
            budget = (t["monthly_budget"] + (subsidy_amount if t["type"] == "Local" else 0) + agent_impact_bonus) * volatility
            if t["type"] == "Expat": budget *= expat_tax_multiplier
            
            best_util, chosen_zone = -1, None
            for z in zones:
                base_price = next((u["market_price"] for u in units if u["zone"] == z), 1000)
                rent_pressure = 1.0 + (settlement_count * 0.0015)
                current_p = base_price * prices[z] * rent_pressure
                
                if current_p <= budget:
                    # Epsilon (Gaussian Random Variable) for human irrationality
                    # epsilon ~ N(0, 0.05^2)
                    epsilon = np.random.normal(0, 0.05) 
                    util = ((budget - current_p) / budget) + epsilon
                    
                    if util > best_util: best_util, chosen_zone = util, z
            
            if chosen_zone: 
                demand[chosen_zone] += 1
            else: priced_out += 1

        for z in zones:
            prices[z] += 0.05 * ((demand[z] - supply[z]) / supply[z])
            prices[z] = max(0.85, prices[z])

    priced_out = max(0, priced_out + np.random.randint(-3, 4))

    results = {
        "prices": prices, 
        "units": units, 
        "priced_out": priced_out,
        "subsidy": subsidy_amount,
        "agent_impact_bonus": agent_impact_bonus
    }

    if write_to_disk:
        with open("results.json", "w") as f:
            json.dump(results, f)

    return results

# Monte Carlo wrapper
def run_monte_carlo(expat_tax=1.0, subsidy=0, iterations=30):
    """Runs the stochastic Walrasian auction N times to find the Expected Value and Variance."""
    displacement_results = []
    for _ in range(iterations):
        res = run_auction(expat_tax_multiplier=expat_tax, subsidy_amount=subsidy, write_to_disk=False)
        displacement_results.append(res['priced_out'])
    
    return {
        "expected_value": np.mean(displacement_results),
        "variance": np.var(displacement_results),
        "distribution": displacement_results
    }
```

### dashboard.py

```python
import streamlit as st
import pandas as pd
import numpy as np
import pydeck as pdk
import os
import json
import matplotlib.pyplot as plt
from solver import run_auction, run_monte_carlo

# --- ⚙️ CONFIG & INITIALIZATION ---
st.set_page_config(page_title="Stochastic Market Simulator", layout="wide")

if 'active_tax' not in st.session_state: st.session_state['active_tax'] = 0
if 'active_subsidy' not in st.session_state: st.session_state['active_subsidy'] = 0

if 'baseline_data' not in st.session_state:
    with st.spinner("Initializing Stochastic Baseline (N=30 runs)..."):
        st.session_state['baseline_data'] = run_auction(1.0, 0)
        # Pre-compute baseline Monte Carlo for comparison (Updated to N=30)
        st.session_state['mc_baseline'] = run_monte_carlo(1.0, 0, iterations=30)

ledger = []
total_aid = 0
if os.path.exists("visa_ledger.json"):
    with open("visa_ledger.json", "r") as f:
        try:
            ledger = json.load(f)
            total_aid = sum(float(x['Amount'].replace('€','')) for x in ledger)
        except: pass

# --- 🎨 MAIN UI HEADER ---
st.title("🎲 Stochastic Housing Equilibrium Simulator")
st.markdown("### CS109 Monte Carlo Policy Analysis via Autonomous Agents")

# --- 🛠️ SIDEBAR ---
st.sidebar.header("🏛️ Policy Controls")
selected_tax = st.sidebar.slider("Expat Luxury Tax (%)", 0, 100, 0)
selected_subsidy = st.sidebar.slider("Visa Monthly Voucher (€)", 0, 500, 0)

if st.sidebar.button("▶️ Run Stochastic Simulation"):
    st.session_state['active_tax'] = selected_tax
    st.session_state['active_subsidy'] = selected_subsidy
    st.session_state['policy_data'] = run_auction(1-(selected_tax/100), selected_subsidy)
    
    # Run new Monte Carlo distribution for the UI (Updated to N=30)
    with st.spinner("Running Monte Carlo simulations to find E[D]..."):
        st.session_state['mc_policy'] = run_monte_carlo(1-(selected_tax/100), selected_subsidy, iterations=30)
    st.toast("Expected Value Updated!", icon="📈")

if len(ledger) > 0 and 'policy_data' not in st.session_state:
    st.session_state['policy_data'] = run_auction(1-(st.session_state['active_tax']/100), st.session_state['active_subsidy'])
    st.session_state['mc_policy'] = run_monte_carlo(1-(st.session_state['active_tax']/100), st.session_state['active_subsidy'], iterations=30)

if 'policy_data' not in st.session_state:
    st.session_state['policy_data'] = st.session_state['baseline_data']

if 'mc_policy' not in st.session_state:
    st.session_state['mc_policy'] = st.session_state['mc_baseline']

# --- TABS FOR UI ---
tab1, tab2 = st.tabs(["🗺️ Geospatial Twin", "📊 Probability & Monte Carlo"])

with tab1:
    p_data = st.session_state['policy_data']
    p_data['subsidy'] = st.session_state['active_subsidy']
    current_displaced = p_data['priced_out']

    m1, m2, m3 = st.columns(3)
    with m1:
        st.metric("📉 Current Displaced Sample", current_displaced, help="A single sample from the distribution.")
    with m2: 
        st.metric("💳 Autonomous Aid Distributed", f"€{total_aid:,.0f}", delta=len(ledger))
    with m3: 
        st.metric("Expected Value E[D]", f"{st.session_state['mc_policy']['expected_value']:.1f}", help="The mathematical expectation of displacement.")

    def get_map(sim_data):
        df = pd.DataFrame(sim_data["units"])
        prices = sim_data["prices"]
        sim_subsidy = sim_data.get('subsidy', 0)
        df['market_rent'] = df.apply(lambda x: round(x['market_price'] * prices[x['zone']]), axis=1)
        df['effective_rent'] = df['market_rent'] - sim_subsidy
        CRISIS_THRESHOLD = 950 
        df['is_affordable'] = df['effective_rent'] <= CRISIS_THRESHOLD
        df['is_saved'] = (sim_subsidy > 0) & (df['effective_rent'] <= CRISIS_THRESHOLD) & (df['market_rent'] > CRISIS_THRESHOLD)

        def color_logic(row):
            if row['is_saved']: return [0, 255, 255, 255]
            if row['is_affordable']: return [0, 255, 120, 160]
            return [255, 0, 60, 160]

        df['color'] = df.apply(color_logic, axis=1)
        layers = [
            pdk.Layer("ScatterplotLayer", df[~df['is_saved']], get_position="[lon, lat]", get_color="color", get_radius=85, opacity=0.4)
        ]
        saved_df = df[df['is_saved']]
        if not saved_df.empty:
            layers.append(pdk.Layer("ScatterplotLayer", saved_df, get_position="[lon, lat]", get_color="color", get_radius=140, stroked=True, get_line_color=[255, 255, 255], get_line_width=20))
        return pdk.Deck(layers=layers, initial_view_state=pdk.ViewState(latitude=39.58, longitude=2.66, zoom=11), map_style="light")

    st.pydeck_chart(get_map(p_data))

with tab2:
    st.markdown("### 🎲 Monte Carlo Analysis of Market Interventions")
    st.markdown(r"Because human utility incorporates a Gaussian random variable $\epsilon \sim \mathcal{N}(0, \sigma^2)$, the exact number of displaced families is a Random Variable $D$. We run $N=30$ simulations to approximate $E[D]$ and $Var(D)$.")
    
    c1, c2 = st.columns(2)
    with c1:
        st.info(f"**Baseline (No Policy):**\n\n$E[D] = {st.session_state['mc_baseline']['expected_value']:.1f}$\n\n$Var(D) = {st.session_state['mc_baseline']['variance']:.1f}$")
    with c2:
        st.success(f"**With Active Agents:**\n\n$E[D|Policy] = {st.session_state['mc_policy']['expected_value']:.1f}$\n\n$Var(D|Policy) = {st.session_state['mc_policy']['variance']:.1f}$")

    # MATPLOTLIB OVERLAPPING HISTOGRAM (High-Res Academic Format)
    fig, ax = plt.subplots(figsize=(8, 4), dpi=300) 
    
    ax.hist(st.session_state['mc_baseline']['distribution'], bins=12, alpha=0.6, color='#e74c3c', edgecolor='black', label='Baseline (No Policy)')
    ax.hist(st.session_state['mc_policy']['distribution'], bins=12, alpha=0.7, color='#00d2d3', edgecolor='black', label='Agent Policy (Active)')
    
    ax.set_title("Probability Distribution of Market Displacement ($N=30$ runs)", fontsize=14, fontweight='bold', pad=15)
    ax.set_xlabel("Number of Displaced Families ($D$)", fontsize
[truncated — 482 more characters]
```