valhalla / backend /src /config.py
Dontcryx_07
final
d660fa9
Raw
History Blame Contribute Delete
19.9 kB
"""config — single source of truth for every tunable in the simulation.
Defines paths, clock parameters (tick length, speed), perception and
conversation radii, replan and budget caps, memory and LLM settings, and
API key handling, with precedence CLI > environment > built-in default.
Architecture: imported by virtually every module; never imports other
project modules, so it can be loaded without side effects.
Design: all settings are overridable via SIM_* environment variables so
experiments can vary parameters without code changes.
"""
from pathlib import Path
import os
try:
from dotenv import load_dotenv
except ImportError:
def load_dotenv(path: str | Path) -> bool:
env_path = Path(path)
if not env_path.exists():
return False
loaded = False
for raw_line in env_path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
loaded = True
return loaded
# DEBUG -
VERBOSE = True
# backend/src/config.py -> backend/src -> backend -> Valhalla (project root)
BASE_DIR = Path(__file__).resolve().parents[2]
# Load the project-local .env so environment variables are available before
# any config values are read.
load_dotenv(BASE_DIR / ".env")
BACKEND_DIR = BASE_DIR / "backend"
DATA_DIR = BACKEND_DIR / "data"
ENVIRONMENT_DIR = DATA_DIR / "environment"
PERSONALITIES_DIR = DATA_DIR / "personalities"
OUTPUT_DIR = BACKEND_DIR / "output"
FRONTEND_DIR = BASE_DIR / "frontend"
PLACES_FILE = ENVIRONMENT_DIR / "places.json"
# Logging
LOG_DIR = OUTPUT_DIR / "logs"
LOG_LEVEL = "DEBUG" if VERBOSE else "INFO"
### Core logic
# ---------------------------------------------------------------------------
# Toggle helpers — read a value from the environment (.env), with a default.
# Every simulation toggle below can be set in .env and overridden on the CLI
# (see apply_overrides() / add_cli_arguments() at the bottom of this file).
# ---------------------------------------------------------------------------
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
return raw.strip().lower() in ("1", "true", "yes", "on", "y")
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name)
try:
return int(raw) if raw not in (None, "") else default
except (TypeError, ValueError):
return default
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name)
try:
return float(raw) if raw not in (None, "") else default
except (TypeError, ValueError):
return default
def _env_str(name: str, default: str) -> str:
raw = os.environ.get(name)
return raw.strip() if raw not in (None, "") else default
# ---------------------------------------------------------------------------
# Spatial awareness
# ---------------------------------------------------------------------------
# Legacy tile-based radius (chebyshev). Kept for the tile-grid perception path.
DEFAULT_PERCEPTION_RADIUS = _env_int("SIM_PERCEPTION_RADIUS_TILES", 5)
# NEW: pixel radius for the proximity circle used for PERCEPTION (who an agent sees).
PERCEPTION_RADIUS_PX = _env_int("SIM_PERCEPTION_RADIUS_PX", 50)
# Pixel radius for starting a CONVERSATION — agents must be this close to talk
# (tighter than perception, so they see each other before they chat).
CONVERSATION_RADIUS_PX = _env_int("SIM_CONVERSATION_RADIUS_PX", 20)
# Whether the perception step runs each tick (0-LLM either way).
PERCEPTION_ENABLED = _env_bool("SIM_PERCEPTION_ENABLED", True)
# Run perception only every N ticks (1 = every tick). Higher = lighter CPU, less
# frequent "who's near me" updates. Perception is 0-LLM regardless.
PERCEPTION_EVERY_N_TICKS = max(1, _env_int("SIM_PERCEPTION_EVERY_N_TICKS", 3))
# Simulation date the run starts on (YYYY-MM-DD). Used to look up / store that
# day's plans and memory. Change this to run/limit a specific day.
SIM_START_DATE = _env_str("SIM_START_DATE", "2026-07-03")
# Time of day the run starts at (HH:MM). 00:00 = midnight (agents asleep at their
# hostels); set e.g. 08:00 to begin during the day so movement is visible at once.
SIM_START_TIME = _env_str("SIM_START_TIME", "00:00")
# ---------------------------------------------------------------------------
# Simulation clock
# SIM_MINUTES_PER_TICK = sim-minutes advanced per engine tick.
# REAL_SECONDS_PER_SIM_MINUTE = wall-clock seconds per 1 sim-minute.
# REAL_SECONDS_PER_TICK = derived (do NOT set manually).
#
# Default is 4.0 real-sec/sim-min => a full sim-day (1440 min) takes ~96 real
# minutes. Clock speed changes observation cadence, not the deterministic
# API-key traversal policy.
# ---------------------------------------------------------------------------
SIM_MINUTES_PER_TICK = _env_int("SIM_MINUTES_PER_TICK", 1)
REAL_SECONDS_PER_SIM_MINUTE = _env_float("SIM_REAL_SECONDS_PER_SIM_MINUTE", 4.0)
REAL_SECONDS_PER_TICK: float = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE
# Global speed multiplier (2.0 = twice as fast, 0.5 = half). Applied on top of
# the clock above by the engine's run loop.
TICK_SPEED = _env_float("SIM_TICK_SPEED", 1.0)
# ---------------------------------------------------------------------------
# Cognition / LLM budget toggles
# ---------------------------------------------------------------------------
# Reflex (react) LLM escalation. LOCKED OFF for the demo — the brain's reflex
# path stays 0-LLM (heuristic "keep going"). Flip to true (SIM_REFLEX_LLM=true)
# to enable the whitelisted reflex escalations in the future.
REFLEX_LLM_ENABLED = _env_bool("SIM_REFLEX_LLM", False)
# At most one reflex LLM escalation per agent per this many sim-minutes.
REFLEX_RATE_LIMIT_MINUTES = _env_int("SIM_REFLEX_RATE_LIMIT_MINUTES", 30)
# Long-term memory is Qdrant-only. Short-term JSON is active-day operational
# state, never a long-term keyword-search fallback.
MEMORY_BACKEND = "vector"
# Cloud Qdrant long-term-memory settings. Credentials are deliberately read
# only from the environment. When unavailable, recall is empty and archival
# keeps its short-term source rather than creating a local long-term archive.
SEMANTIC_MEMORY_ENABLED = _env_bool("SIM_SEMANTIC_MEMORY_ENABLED", True)
QDRANT_URL = _env_str("QDRANT_URL", "")
QDRANT_API_KEY = _env_str("QDRANT_API_KEY", "")
MEMORY_EMBEDDING_MODEL = _env_str("SIM_MEMORY_EMBEDDING_MODEL", "gemini-embedding-001")
MEMORY_VECTOR_DIMENSIONS = _env_int("SIM_MEMORY_VECTOR_DIMENSIONS", 768)
MEMORY_COLLECTION_VERSION = _env_str("SIM_MEMORY_COLLECTION_VERSION", "v1")
MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER = max(1, _env_int("SIM_MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER", 4))
MEMORY_RECENCY_DECAY_DAYS = max(1.0, _env_float("SIM_MEMORY_RECENCY_DECAY_DAYS", 30.0))
# Application-side quota guard for Cloud Qdrant. Qdrant Cloud reports actual
# plan usage in its dashboard; this estimate keeps the index below the chosen
# local budget before that dashboard limit is approached.
MEMORY_MAX_STORAGE_GB = max(0.1, _env_float("SIM_MEMORY_MAX_STORAGE_GB", 4.0))
MEMORY_STORAGE_PRUNE_THRESHOLD = min(1.0, max(0.1, _env_float("SIM_MEMORY_STORAGE_PRUNE_THRESHOLD", 0.90)))
MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env_float("SIM_MEMORY_STORAGE_PRUNE_TARGET", 0.85)))
# Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
# Deterministic backstop for agents stranded on the "Unscheduled downtime"
# fallback schedule. Every tick, the engine checks remaining plans; an
# agent with upcoming downtime gets a remaining-day replan. The cooldown
# (in ticks) stops a repeatedly-rejected replan from hammering the planner,
# and the horizon (in minutes) skips replans when too little of the day is
# left to be worth one.
DOWNTIME_REPLAN_COOLDOWN_TICKS = _env_int("SIM_DOWNTIME_REPLAN_COOLDOWN_TICKS", 60)
DOWNTIME_REPLAN_MIN_HORIZON = _env_int("SIM_DOWNTIME_REPLAN_MIN_HORIZON", 60)
# Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
# 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
# defer replans) — the sim keeps running on the 0-LLM executor path.
LLM_HOURLY_CEILING = _env_int("SIM_LLM_HOURLY_CEILING", 0)
# Energy and emotion thresholds for LLM calls
DECIDE_MIN_ENERGY = _env_float("SIM_DECIDE_MIN_ENERGY", 0.1)
DECIDE_MIN_EMOTION = _env_float("SIM_DECIDE_MIN_EMOTION", 0.1)
# Web login admin credentials (email:password pairs, semicolon-separated)
ADMIN_CREDENTIALS = _env_str("ADMIN_CREDENTIALS", "")
# An observation may change every tick while agents travel together. Limit
# each agent's advisory LLM reflex to avoid turning normal movement into an
# unbounded API stream; their action state machines still run every tick.
DECIDE_COOLDOWN_TICKS = _env_int("SIM_DECIDE_COOLDOWN_TICKS", 30)
CONVERSATION_MIN_ENERGY = _env_float("SIM_CONVERSATION_MIN_ENERGY", 0.05)
CONVERSATION_MIN_EMOTION = _env_float("SIM_CONVERSATION_MIN_EMOTION", 0.05)
# LLM creativity profile. This single observer-facing setting controls how
# varied plans, decisions, and conversations may be while keeping memory
# summaries intentionally stable. 0.0 is conservative; 1.0 is lively.
SIM_CREATIVITY = min(1.0, max(0.0, _env_float("SIM_CREATIVITY", 1.0)))
# Controls the size of non-LLM wellbeing variation. This is deliberately
# independent from creativity: an observer can ask for more varied plans
# without making students' energy and mood unrealistically volatile.
SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
# How fast the runtime glides an agent's energy toward the day planner's
# declared per-action energy_target. This is a control rate, not an energy
# value: 0.0 disables the glide (pure delta-based energy, as before), larger
# values converge faster (0.03 => ~84% of the gap closed per 60-min action).
SIM_ENERGY_FOLLOW_RATE = _env_float("SIM_ENERGY_FOLLOW_RATE", 0.03)
TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
SUMMARY_TEMPERATURE = 0.5
# The simulation uses a primary model, with a same-key fallback model.
# ``gemini_client`` tries the primary model on each key, then the fallback
# model on the same key, before advancing to the next key; that key
# rotation is the only provider-recovery behaviour.
GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.5-flash-lite")
GEMINI_MODEL_FALLBACK = _env_str("SIM_GEMINI_MODEL_FALLBACK", "gemini-3.1-flash-lite")
# Support multiple API keys (comma-separated in env var). When numbered
# variables are used, they are read in ascending numeric order.
# ``gemini_client`` rebuilds its circular linked list from this ordered list
# for every API call, beginning at index 1 each time.
API_KEYS: list[str] = [
k.strip() for k in os.environ.get("GEMINI_API_KEYS", "").split(",") if k.strip()
]
if not API_KEYS:
# Gather GEMINI_API_KEY (no suffix) + GEMINI_API_KEY_1 through _100
# Only includes keys that are actually set (non-empty).
numbered_keys = []
for index in range(1, 101):
key = os.environ.get(f"GEMINI_API_KEY_{index}", "")
if key:
numbered_keys.append(key)
API_KEYS = [key for key in [os.environ.get("GEMINI_API_KEY", ""), *numbered_keys] if key]
# Drop obvious placeholder values before building the deterministic key ring.
_PLACEHOLDER_MARKERS = ("your_google_api_key", "your_api_key", "changeme", "xxxx")
API_KEYS = [
k for k in API_KEYS
if not any(marker in k.lower() for marker in _PLACEHOLDER_MARKERS)
]
API_KEY_COUNT = len(API_KEYS)
# Conversation cap (per agent per day)
MAX_CONVERSATIONS_PER_AGENT = _env_int("SIM_MAX_CONVERSATIONS_PER_AGENT", 5)
# At the calendar boundary, allow active conversation jobs this many real
# seconds to finish and persist before the engine cancels the remainder.
DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS = _env_float(
"SIM_DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS", 45.0,
)
# day_planner.py CONFIG
MAX_PLAN_RETRIES = 3
# What is expected in persona.json file
PERSONA_FIELD_GLOSSARY = {
"daily_plan_req": "A rough sketch of their typical day — classes, work, recurring commitments.",
"innate": "Personality traits they were simply born with (natural disposition).",
"learned": "Skills/knowledge acquired since starting college (not innate).",
"lifestyle": "Habits and routines: sleep schedule, exercise, social patterns.",
"hobbies": "Free-time activities they actively enjoy.",
"goals": "Short- and long-term goals, academic and personal.",
}
# ---------------------------------------------------------------------------
# CLI + runtime override surface
# ---------------------------------------------------------------------------
# Every toggle above has a default (hard-coded) that can be overridden by an
# environment variable (.env), which can in turn be overridden by a CLI flag.
# Precedence: CLI flag > .env > built-in default.
# Maps CLI/override keyword -> config global name it sets.
_OVERRIDE_MAP = {
"tick_speed": "TICK_SPEED",
"real_seconds_per_sim_minute": "REAL_SECONDS_PER_SIM_MINUTE",
"sim_minutes_per_tick": "SIM_MINUTES_PER_TICK",
"perception_radius_px": "PERCEPTION_RADIUS_PX",
"perception_enabled": "PERCEPTION_ENABLED",
"max_conversations_per_agent": "MAX_CONVERSATIONS_PER_AGENT",
"max_replans_per_agent_per_day": "MAX_REPLANS_PER_AGENT_PER_DAY",
"llm_hourly_ceiling": "LLM_HOURLY_CEILING",
"decide_min_energy": "DECIDE_MIN_ENERGY",
"decide_min_emotion": "DECIDE_MIN_EMOTION",
"decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
"conversation_min_energy": "CONVERSATION_MIN_ENERGY",
"conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
"energy_follow_rate": "SIM_ENERGY_FOLLOW_RATE",
"day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
}
def apply_overrides(**overrides) -> None:
"""Apply runtime overrides (typically parsed CLI args) onto the module
globals. Only non-None values are applied. Recomputes derived constants."""
global REAL_SECONDS_PER_TICK
for key, value in overrides.items():
if value is None:
continue
target = _OVERRIDE_MAP.get(key)
if target is None:
continue
globals()[target] = value
# Recompute derived clock constant.
REAL_SECONDS_PER_TICK = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE
def add_cli_arguments(parser) -> None:
"""Register the toggle flags on an argparse parser. Defaults are None so
that an unspecified flag leaves the .env/built-in value untouched."""
g = parser.add_argument_group("simulation toggles (override .env)")
g.add_argument("--tick-speed", dest="tick_speed", type=float, default=None,
help="Speed multiplier (2.0 = 2x faster). Default 1.0")
g.add_argument("--real-seconds-per-sim-minute", dest="real_seconds_per_sim_minute",
type=float, default=None,
help="Wall-clock seconds per sim-minute (7.5 => 180 real-min/day)")
g.add_argument("--sim-minutes-per-tick", dest="sim_minutes_per_tick", type=int,
default=None, help="Sim-minutes advanced per tick (default 1)")
g.add_argument("--perception-radius-px", dest="perception_radius_px", type=int,
default=None, help="Proximity circle radius in pixels (default 50)")
g.add_argument("--perception", dest="perception_enabled", type=_str2bool, default=None,
help="Run the perception step each tick (default: on)")
g.add_argument("--max-conversations", dest="max_conversations_per_agent", type=int,
default=None, help="Max conversations per agent per day (default 5)")
g.add_argument("--max-replans", dest="max_replans_per_agent_per_day", type=int,
default=None, help="Max mid-day full replans per agent (default 3)")
g.add_argument("--llm-hourly-ceiling", dest="llm_hourly_ceiling", type=int,
default=None, help="Soft LLM calls/real-hour ceiling (0 = none)")
g.add_argument("--decide-min-energy", dest="decide_min_energy", type=float,
default=None, help="Min energy for LLM decide (default 0.1)")
g.add_argument("--decide-min-emotion", dest="decide_min_emotion", type=float,
default=None, help="Min emotion for LLM decide (default 0.1)")
g.add_argument("--conv-min-energy", dest="conversation_min_energy", type=float,
default=None, help="Min energy for conversation (default 0.05)")
g.add_argument("--conv-min-emotion", dest="conversation_min_emotion", type=float,
default=None, help="Min emotion for conversation (default 0.05)")
g.add_argument("--day-handoff-conversation-timeout", dest="day_handoff_conversation_timeout_seconds", type=float,
default=None, help="Real seconds to wait for conversations at day rollover (default 45)")
def _str2bool(value: str) -> bool:
return str(value).strip().lower() in ("1", "true", "yes", "on", "y")
def overrides_from_args(args) -> dict:
"""Extract the override kwargs from a parsed argparse namespace."""
return {key: getattr(args, key, None) for key in _OVERRIDE_MAP}
def describe_settings() -> str:
"""Human-readable one-block summary of all active simulation toggles."""
# sim-day = 1440 sim-min; real seconds = 1440 * REAL_SECONDS_PER_SIM_MINUTE / TICK_SPEED
day_real_min = (1440 * REAL_SECONDS_PER_SIM_MINUTE / max(TICK_SPEED, 1e-9)) / 60.0
return (
"Valhalla simulation settings\n"
f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
f" Gemini model : {GEMINI_MODEL} (fallback {GEMINI_MODEL_FALLBACK} on same key)\n"
f" Simulation creativity : {SIM_CREATIVITY:.2f} "
f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
f" Energy follow rate : {SIM_ENERGY_FOLLOW_RATE:.3f}/sim-min\n"
f" Memory backend : {MEMORY_BACKEND}\n"
f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
f" Sim clock : {SIM_MINUTES_PER_TICK} sim-min/tick, "
f"{REAL_SECONDS_PER_SIM_MINUTE}s/sim-min, speed x{TICK_SPEED}\n"
f" => real sec/tick : {REAL_SECONDS_PER_TICK / max(TICK_SPEED,1e-9):.2f}s\n"
f" => real min per sim-day : ~{day_real_min:.0f} min\n"
f" Max conversations/agent/day : {MAX_CONVERSATIONS_PER_AGENT}\n"
f" Max replans/agent/day : {MAX_REPLANS_PER_AGENT_PER_DAY}\n"
f" LLM hourly ceiling : {LLM_HOURLY_CEILING or 'none'}\n"
f" Decide min energy/emotion : {DECIDE_MIN_ENERGY} / {DECIDE_MIN_EMOTION}\n"
f" Conv min energy/emotion : {CONVERSATION_MIN_ENERGY} / {CONVERSATION_MIN_EMOTION}\n"
f" Day-handoff conversation wait: {DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS:.0f}s\n"
)
if __name__ == '__main__':
print(f"Running this project from : {BASE_DIR}")
print(describe_settings())