From f950c1463d97ac842808e7b6b9d70e08d739b3e4 Mon Sep 17 00:00:00 2001 From: Merengues0x2A Date: Fri, 10 Jul 2026 09:59:36 +0800 Subject: [PATCH 01/35] docs: define digital evolution reboot --- ...10-digital-evolution-core-reboot-design.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md diff --git a/docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md b/docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md new file mode 100644 index 0000000..3afc50d --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md @@ -0,0 +1,630 @@ +# AnteLab Digital Evolution Core Reboot + +> Status: Approved product direction; implementation pending written-spec review +> Date: 2026-07-10 +> Scope: Product identity, evolutionary substrate, V1 architecture, proof standard, +> repository reset boundary + +## 1. Decision + +AnteLab will be rebooted as an open-source digital evolution laboratory. + +The product is no longer primarily an AI reality show, a company simulator, an +agent receipt layer, or an LLM-agent benchmark. Those ideas may survive only as +historical context or subordinate instrumentation. + +The new product thesis is: + +> Start with primitive organisms and stable physics. Watch behavior, lineages, +> species, and ecosystems emerge across generations. + +The V1 headline is: + +> **No prompts. No goals. Just physics, mutation, and selection.** + +The core V1 mechanism is cross-generational population evolution. Lifetime +learning and neural plasticity are Phase 2. Co-evolving environments are Phase 3. +LLMs are optional observers or later cognition plugins, never the default life +substrate. + +## 2. Problem + +The previous AnteLab stack had lifecycle mechanics, deterministic experiments, +replay, and a visual observer, but it did not implement biological evolution. + +In the last committed implementation: + +- agents were initialized from hand-authored personalities; +- every active agent delegated its decision to the same configured LLM; +- newborns received the same LLM and a fixed newborn personality; +- births and deaths existed, but genomes, heredity, recombination, mutation, and + genotype-to-phenotype mapping did not; +- company, receipt, narrative, and observer concerns dominated the product; +- free-form natural-language actions made behavior expressive but prevented a + clean, evolvable sensor-to-effector contract. + +This can produce a society simulation, but it cannot support the claim that +behavior evolved from primitive beginnings. Pretrained model behavior would be +mistaken for emergence, and per-agent LLM calls would make large populations and +long evolutionary runs too expensive. + +## 3. Product Goals + +V1 must make five things simultaneously true. + +### 3.1 Watchable + +A user can open the local web experience and immediately see organisms moving, +feeding, reproducing, dying, branching into lineages, and changing across time. + +### 3.2 Evolutionary + +Every surviving behavioral difference must be traceable to heritable variation +and differential reproduction under environmental pressure. Birth alone is not +evolution. + +### 3.3 Reproducible + +The same engine version, configuration, seed, and tick count must produce the +same simulation checksum and lineage graph on supported platforms. + +### 3.4 Falsifiable + +AnteLab must not label visual novelty as evolution without controls. Evolutionary +claims require ancestor comparisons, mutation-disabled controls, repeated seeds, +and machine-readable artifacts. + +### 3.5 Extensible + +Contributors must be able to add environments, genes, sensors, effectors, +mutation operators, observers, metrics, and visualization layers without editing +an all-purpose world module. + +## 4. Non-Goals for V1 + +V1 will not include: + +- LLM calls in the organism decision loop; +- free-form natural-language actions; +- company formation, valuation, IPO, hiring, departments, or market scenarios; +- public agent upload, public leaderboards, accounts, or hosted evaluation; +- agent truthfulness or receipt verification as the product identity; +- sexual reproduction or recombination; +- learned language, culture, tool use, or multi-agent institutions; +- arbitrary user code execution; +- GPU-only execution; +- claims of open-ended intelligence or indefinite complexity growth; +- simultaneous co-evolution of organisms and environments; +- backward compatibility with historical company or receipt artifacts. + +These exclusions are not judgments that the features lack future value. They +protect the first proof from ambiguity and infrastructure sprawl. + +## 5. First-Principles Constraints + +### 5.1 Stable Physics, Unstructured Initial Conditions + +"Chaos" means an unorganized population with random positions and small genetic +variation. It does not mean constantly changing causal rules. Selection cannot +accumulate information when useful adaptations stop being useful before they can +be inherited. + +### 5.2 No Explicit Fitness Score + +The engine does not award an abstract fitness number. An organism succeeds only +by remaining alive long enough to reproduce. Analysis may compute descriptive +fitness proxies after the fact, but those metrics do not directly select parents. + +### 5.3 Energy and Matter Accounting + +Movement, sensing, signalling, metabolism, and reproduction consume energy. +Food adds bounded energy and is created only by declared environment processes. +Reproduction transfers energy from parent to child. State updates must not create +unaccounted energy. + +### 5.4 Local Information + +Organisms receive only local sensor values and their own internal state. They do +not receive the global map, lineage rankings, population metrics, or hidden food +locations. + +### 5.5 Bounded Evolvable Interface + +Organism behavior is a deterministic mapping from a fixed V1 sensor vector to a +fixed V1 effector vector. A bounded interface makes genotype mutation, +inheritance, replay, testing, and causal analysis possible. + +### 5.6 Engine and Observer Separation + +Rendering, narrative summaries, metrics dashboards, and artifact exploration +must not influence simulation state. The headless engine is authoritative. + +### 5.7 Deterministic Numerics + +Authoritative positions, headings, energy values, gene values, controller +weights, and controller activations use documented fixed-point integer units. +Rendering may convert those values to floating point, but rendered values never +feed back into the engine. + +The V1 controller uses a bounded integer activation function or a versioned +lookup table rather than platform-dependent transcendental functions. The engine +owns all random streams; authoritative code must not use module-global random +state. These constraints make state checksums meaningful across supported CPU +platforms instead of merely repeatable on one developer machine. + +## 6. V1 World + +V1 uses a continuous two-dimensional toroidal world. Crossing one boundary +returns the organism on the opposite side. This avoids boundary death becoming +the dominant evolutionary strategy while keeping spatial distance meaningful. + +The world contains: + +- organisms; +- food particles or patches; +- a deterministic food regeneration process; +- optional spatially varying but time-stable fertility zones; +- no predators, obstacles, seasons, disease, weather, crafting, or company + systems in the first proof. + +The initial population is randomly positioned. The initial genomes are derived +from one primitive ancestor genome plus bounded mutations. Completely arbitrary +random neural networks are not required; most would be inert and would turn the +first release into an extinction simulator rather than an evolution experiment. + +## 7. V1 Digital Organism + +Each organism has an immutable identity, a parent identity, a generation number, +a genome, a phenotype, and runtime state. + +Runtime state includes: + +- position and heading; +- linear speed; +- energy; +- age; +- alive/dead state; +- last reproduction tick; +- optional emitted signal value; +- cumulative food consumed and offspring count for analysis only. + +### 7.1 Genome + +The V1 genome contains bounded numeric genes for: + +- body radius; +- maximum thrust; +- turn rate; +- basal metabolism; +- movement energy cost; +- sensor range; +- field of view; +- reproduction energy threshold; +- reproduction energy allocation; +- signal strength and signal cost; +- organism color channels for visible lineage variation; +- neural-controller weights and biases. + +Every gene declares its range, mutation scale, and mutation probability. Mutation +must clamp or transform values into valid ranges. Genome serialization is +canonical and versioned. + +### 7.2 Sensors + +The fixed V1 sensor vector is: + +1. normalized own energy; +2. normalized own age; +3. nearest-food distance within sensor range; +4. signed nearest-food bearing; +5. local food density; +6. nearest-organism distance within sensor range; +7. signed nearest-organism bearing; +8. nearest-organism signal value; +9. local organism density; +10. constant bias input. + +Missing targets use stable sentinel values defined by the engine contract. + +### 7.3 Controller + +V1 uses a small, fixed-topology, fixed-point feed-forward neural network. Weights +and biases are inherited and mutated. A fixed topology is intentionally less +ambitious than NEAT-style topology evolution, but is easier to test, serialize, +visualize, and run at population scale. + +Topology evolution becomes a separate future design only after weight evolution +shows repeatable adaptation. + +### 7.4 Effectors + +The controller emits bounded values for: + +1. turn left/right; +2. forward thrust; +3. eat attempt; +4. signal emission; +5. reproduce attempt. + +The world resolves outputs against physics. An output is not guaranteed to +succeed. For example, eating requires food within reach, and reproduction +requires minimum energy and cooldown conditions. + +### 7.5 Reproduction + +V1 reproduction is asexual. + +When reproduction succeeds: + +- the parent pays a configured energy cost; +- a configured portion of energy is transferred to the child; +- the child genome is a mutated copy of the parent genome; +- the child is placed near the parent without overlapping invalid geometry; +- a lineage edge and birth event are recorded; +- the child begins with no lifetime memory or learned state. + +Asexual reproduction isolates heredity and mutation. Sexual reproduction, +recombination, mate choice, and parental investment are postponed until the +baseline lineage system is proven. + +### 7.6 Death + +An organism dies when energy reaches zero or maximum age is exceeded. Dead +organisms are removed from active decision updates. V1 does not recycle carcass +matter; if added later, it must be included in matter accounting. + +## 8. Simulation Loop + +Each tick runs in this order: + +1. build spatial index; +2. read immutable sensor snapshots for all living organisms; +3. evaluate every controller; +4. resolve movement from the shared start-of-tick snapshot; +5. resolve eating conflicts using a deterministic keyed tie-breaker derived from + the run seed, tick, resource identity, distance, and contender identities; +6. resolve signalling; +7. charge metabolism and action costs; +8. resolve reproduction; +9. resolve deaths; +10. regenerate declared food; +11. record metrics, events, lineage changes, and optional checkpoint; +12. advance the tick. + +All organisms perceive the start-of-tick state. No organism sees another +organism's action from the same tick. This preserves deterministic synchronous +semantics and prevents order-dependent perception. Raw organism ID ordering must +not systematically decide contested resources or reproduction opportunities. + +## 9. Architecture + +The proposed Python package boundaries are: + +```text +antelab/ + core/ + config.py # validated, versioned simulation configuration + rng.py # simulation-owned deterministic random streams + genome.py # gene schema, canonical serialization, mutation + brain.py # fixed-topology controller and sensor/effector contracts + organism.py # organism identity, phenotype, and runtime state + environment.py # food fields and stable world processes + spatial.py # neighbor and food queries + physics.py # movement, reach, energy, and collision rules + evolution.py # reproduction, inheritance, mutation, lineage events + simulation.py # authoritative tick orchestration + experiments/ + runner.py # headless single and multi-seed execution + controls.py # mutation-off and ancestor-replay controls + metrics.py # descriptive population and adaptation metrics + artifacts/ + schema.py # versioned run, event, checkpoint, and lineage contracts + writer.py # bounded streaming artifact output + api/ + server.py # local read/control API over one simulation owner +``` + +The frontend remains a separate application and consumes only versioned API or +artifact contracts. No frontend component imports or reimplements engine logic. + +## 10. Public Interfaces + +### 10.1 CLI + +The intended V1 commands are: + +```text +antelab run experiments/foraging-genesis.yaml --seed 42 --ticks 10000 +antelab compare artifacts/run-a.json artifacts/run-b.json +antelab verify artifacts/run.json +antelab serve artifacts/run.json +``` + +Exact command names may change in the implementation plan, but the user journey +must stay one-command local and must not require an API key. + +### 10.2 Artifact + +Every run artifact contains: + +- schema and engine version; +- complete normalized configuration; +- seed and deterministic run identity; +- initial ancestor genome; +- final population summary; +- time-series population, energy, food, birth, death, and diversity metrics; +- lineage nodes and edges; +- sampled phenotype/genome distributions; +- declared control condition; +- periodic state checksums; +- notable-event references; +- optional checkpoint paths. + +Large per-tick organism state must be stored in bounded checkpoints or compressed +replay chunks, not repeated in one unbounded JSON document. + +### 10.3 Observer + +The V1 observer has one primary screen: + +- full-bleed living world; +- play, pause, step, speed, and scrub controls; +- population, food, generation, and simulation-time indicators; +- selectable organism inspection; +- ancestor and descendant navigation; +- genome/phenotype comparison; +- lineage tree; +- population and trait-distribution charts; +- notable-event timeline; +- visible seed and configuration identity. + +The first screen is the simulation, not a marketing landing page and not an +operations dashboard. + +## 11. Proof Experiment + +V1 is not complete merely when organisms animate and reproduce. It must pass a +predefined experiment. + +### 11.1 Treatment + +- stable foraging world; +- primitive ancestor controller; +- mutation enabled; +- multiple independent seeds; +- fixed tick budget per seed. + +### 11.2 Controls + +- identical configuration with mutation disabled; +- ancestor genome replayed in the final environment; +- deterministic rerun of at least one treatment seed. + +### 11.3 Evidence + +The report compares descendants with ancestors and controls using: + +- food acquired per unit energy spent; +- lifetime reproductive success; +- population persistence; +- generation depth; +- genome and phenotype diversity; +- lineage survival distribution; +- adaptation retention when descendants are replayed from a clean initial state. + +Exact success thresholds will be preregistered in the implementation plan after +a calibration-only baseline. Thresholds must not be selected after inspecting +the final treatment result. + +### 11.4 Claim Boundary + +Passing the proof supports only this claim: + +> Heritable controller and trait variation produced repeatable improvement in +> foraging and reproduction under the declared environment. + +It does not support claims of intelligence, consciousness, open-ended evolution, +culture, or general adaptation. + +## 12. Testing and Verification + +Required test layers: + +### Unit + +- canonical genome round-trip; +- deterministic mutation with fixed RNG state; +- mutation range enforcement; +- controller shape and bounded outputs; +- sensor sentinel behavior; +- energy debit and transfer accounting; +- reproduction inheritance; +- death conditions; +- toroidal distance and movement. + +### Integration + +- identical seeds produce identical state checksums and lineage graphs; +- different seeds can diverge without violating invariants; +- headless run completes without frontend or API dependencies; +- artifacts validate against the declared schema; +- checkpoints resume to the same terminal checksum as uninterrupted execution; +- mutation-disabled controls produce no genetic divergence. + +### Property and Invariant + +- organism energy never becomes NaN or negative after resolution; +- genome values always remain in declared ranges; +- every non-founder organism has exactly one valid parent in V1; +- generation equals parent generation plus one; +- total energy changes only through declared food generation, metabolism, and + configured sinks/sources; +- the observer cannot mutate engine state except through explicit control APIs. + +### Performance + +The implementation plan must define a CPU reference machine and population/tick +budget. V1 must run a meaningful multi-generation proof without CUDA, a discrete +GPU, or network calls. + +## 13. Error Handling + +- Invalid configs fail before simulation startup with field-specific errors. +- Invalid or incompatible artifact versions fail explicitly; they are never + silently reinterpreted. +- A population extinction is a valid experimental result, not an engine error. +- Numeric instability, NaN, broken lineage references, checksum mismatch, or + unaccounted energy are engine errors and fail the run. +- A configured entity or artifact-size safety limit stops the run with an + explicit `capacity_exceeded` outcome. The engine must not silently cull a + population to stay within resource limits. +- The API reports simulation failure separately from a scientifically valid + extinction outcome. + +## 14. Open-Source Contribution Model + +The repository will support contributions in separately reviewable categories: + +- `experiments/`: environment and treatment/control definitions; +- `organisms/`: canonical ancestor genomes and discovered lineages; +- `antelab/core/`: engine primitives and evolutionary mechanisms; +- `antelab/experiments/`: metrics and proof methodology; +- `frontend/`: observer and analysis tools; +- `docs/discoveries/`: reproducible findings with artifact references. + +Shared genomes and runs must declare engine version, schema version, seed, +license, and reproduction command. Generated bulk artifacts stay outside Git by +default; curated small fixtures and discoveries may be committed. + +## 15. Repository Reset Boundary + +The current working tree already marks the entire historical project as deleted, +while the Git object history remains intact. The reboot will treat Git history as +the archive instead of restoring obsolete files into an `archive/` directory. + +### 15.1 Keep or Recreate + +The reboot keeps or recreates only: + +- Git history and repository identity; +- project license, after confirming its intended reuse; +- a minimal Python package and dependency definition; +- focused CI for the new engine; +- the approved digital-evolution design and future implementation plan; +- a rewritten README, contribution guide, architecture guide, and runbook; +- new engine, experiment, artifact, API, frontend, and test files; +- selectively ported deterministic-run, artifact, replay, and visualization ideas + when they fit the new contracts. + +### 15.2 Permanently Retire from the New Tree + +The new tree will not restore: + +- company market, organization, valuation, space, cast, season, and shock code; +- company scenarios and generated company replay artifacts; +- LLM client and narrative code as a default dependency; +- receipt-first product code, receipt demo fixtures, and receipt-specific UI; +- historical company, observer, receipt, and artifact-workbench specs; +- historical screenshots, UI scan passes, videos, and QA checkpoints; +- tests whose only purpose is retired behavior; +- obsolete Docker, workflow, configuration, and documentation files; +- generated data that can be reproduced from source. + +### 15.3 Selective Salvage Rule + +No historical file is restored wholesale merely because some logic may be useful. +Implementation may read `HEAD:` and port the smallest justified behavior +into the new structure with new tests. This prevents old product assumptions from +re-entering through copied modules. + +### 15.4 Git Safety Rule + +The implementation must never use bulk staging. Each commit explicitly stages +the new files and intended deletions for one coherent slice. Before every commit: + +```text +git diff --cached --name-status +git diff --cached --stat +``` + +must be reviewed. Existing deletions are not committed until the replacement +skeleton and its validation for that slice are present. + +## 16. Delivery Phases + +This reboot is too large for one implementation plan. It is decomposed into +independently reviewable subprojects: + +1. **Deterministic evolution kernel:** repository skeleton, validated config, + owned RNG streams, genome, fixed controller, organism state, spatial queries, + physics, food, energy, asexual reproduction, mutation, death, lineage, a + headless runner, and a minimal artifact. This is the only scope of the first + implementation plan. +2. **Evolution proof harness:** treatment/control orchestration, ancestor replay, + multi-seed metrics, checkpoints, comparison reports, and preregistered proof + thresholds. +3. **Watchable observer:** local API, replay transport, living-world renderer, + organism inspector, lineage tree, charts, and notable-event navigation. +4. **Advanced evolution:** lifetime learning, richer ecology, sexual + reproduction, topology evolution, and co-evolving environments. Each advanced + mechanism requires its own design approval before implementation. + +The first plan may prepare interfaces needed by later subprojects, but it must +not implement their behavior speculatively. + +### Phase 0: Repository Reboot Skeleton + +- minimal package, tests, CI, README, license, and configuration; +- no historical runtime restored; +- deterministic RNG and config contracts established. + +### Phase 1: Real Evolution Core + +- genome, controller, organism, physics, food, energy, asexual reproduction, + mutation, death, lineage, headless runner, and artifacts; +- proof experiment and controls run headlessly. + +### Phase 2: Watchable Observer + +- local API, replay/checkpoints, living-world renderer, organism inspector, + lineage tree, charts, and notable events; +- one-command local demo without API keys. + +### Phase 3: Lifetime Learning + +- inherited plasticity genes and bounded within-life adaptation; +- experiments separating inherited behavior from learned behavior; +- Baldwin-effect-style analyses only after the baseline is stable. + +### Phase 4: Co-evolution and Richer Ecology + +- sexual reproduction and recombination; +- predators, carcass recycling, signals/pheromones, obstacles, niches; +- environment mutation and organism-environment co-evolution; +- topology evolution or modular body plans; +- optional LLM observer or high-level cognition plugin. + +## 17. Acceptance Criteria for This Design + +This design is ready for implementation planning when the user confirms all of +the following: + +1. Cross-generational population evolution is the V1 product identity. +2. LLMs are excluded from the default organism loop. +3. V1 uses a 2D continuous toroidal world and a fixed-topology inherited neural + controller. +4. V1 uses asexual reproduction and postpones lifetime learning. +5. The repository is rebooted in place, with Git history as the archive and old + product files permanently absent from the new tree. +6. The first release is not accepted until it passes a controlled, + machine-readable adaptation experiment. + +## 18. Deferred Decisions + +The implementation plan may choose concrete numeric defaults for population +size, tick rate, mutation rates, world dimensions, food regeneration, checkpoint +cadence, and proof thresholds after a clearly labeled calibration run. + +It may not change the product identity, inheritance model, no-LLM baseline, +headless-engine authority, or falsifiable-proof requirement without a design +revision approved by the user. From b5502aaf96ff3a05a3ca6c3d318c79590f20c0c8 Mon Sep 17 00:00:00 2001 From: Merengues0x2A Date: Fri, 10 Jul 2026 10:40:09 +0800 Subject: [PATCH 02/35] docs: plan deterministic evolution kernel --- ...26-07-10-deterministic-evolution-kernel.md | 2808 +++++++++++++++++ 1 file changed, 2808 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md diff --git a/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md new file mode 100644 index 0000000..5bbc392 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md @@ -0,0 +1,2808 @@ +# Deterministic Evolution Kernel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reboot AnteLab into a clean, CPU-only, deterministic headless kernel in which fixed-point digital organisms inherit mutable genomes, sense a toroidal food world, act through a bounded controller, reproduce, die, form lineages, and emit a versioned run artifact. + +**Architecture:** Replace the deleted company/LLM/receipt stack with small standard-library-first modules under `antelab/core`, plus a headless experiment runner and artifact writer. Authoritative state uses integer fixed-point units and a repository-owned SplitMix64 random generator; the frontend, API, multi-seed proof harness, checkpoints, and lifetime learning remain outside this plan. + +**Tech Stack:** Python 3.12, dataclasses, argparse, hashlib/json, PyYAML, pytest, Ruff, mypy, uv, GitHub Actions. + +--- + +## Scope and Starting State + +The approved design is +`docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md`. + +The starting branch is `main`. Commit `f950c14` adds the approved design. The +working tree already marks the historical tracked tree as deleted; none of those +deletions are staged. Task 1 replaces the minimum repository skeleton, validates +it, then explicitly stages the approved retirement boundary together with the +replacement skeleton. + +This plan implements only subproject 1 from the design: + +- repository reboot skeleton; +- deterministic fixed-point primitives; +- validated single-run configuration; +- genome, mutation, and a fixed 10-input/5-output controller; +- organism, food, spatial query, movement, energy, reproduction, and death; +- deterministic tick orchestration and lineage events; +- a minimal versioned artifact; +- headless runner and CLI; +- one deterministic smoke experiment. + +This plan does not implement: + +- statistical adaptation claims or multi-seed controls; +- ancestor replay or checkpoint resume; +- HTTP/WebSocket APIs; +- frontend rendering; +- lifetime learning, sexual reproduction, recombination, predators, seasons, or + co-evolving environments; +- LLM dependencies or network calls. + +## Locked File Structure + +Files present after this plan: + +```text +.github/workflows/ci.yml +.gitignore +AGENTS.md +ARCHITECTURE.md +CONTRIBUTING.md +LICENSE +Makefile +README.md +RUNNING.md +pyproject.toml +uv.lock +antelab/__init__.py +antelab/cli.py +antelab/core/__init__.py +antelab/core/brain.py +antelab/core/config.py +antelab/core/environment.py +antelab/core/evolution.py +antelab/core/fixed.py +antelab/core/genome.py +antelab/core/organism.py +antelab/core/physics.py +antelab/core/rng.py +antelab/core/simulation.py +antelab/core/spatial.py +antelab/artifacts/__init__.py +antelab/artifacts/schema.py +antelab/artifacts/writer.py +antelab/experiments/__init__.py +antelab/experiments/runner.py +docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md +docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md +experiments/foraging-genesis.yaml +scripts/benchmark_kernel.py +tests/__init__.py +tests/artifacts/test_writer.py +tests/core/test_brain.py +tests/core/test_config.py +tests/core/test_environment.py +tests/core/test_evolution.py +tests/core/test_fixed.py +tests/core/test_genome.py +tests/core/test_physics.py +tests/core/test_rng.py +tests/core/test_simulation.py +tests/core/test_spatial.py +tests/experiments/test_runner.py +tests/helpers.py +tests/test_cli.py +tests/test_package.py +``` + +Responsibilities are non-overlapping: + +- `fixed.py`: units, headings, toroidal geometry, integer normalization. +- `rng.py`: deterministic random streams and keyed conflict ranking. +- `config.py`: YAML parsing, validation, normalized configuration. +- `genome.py`: heritable traits, controller genes, canonical form, mutation. +- `brain.py`: sensor/effector contracts and fixed-point controller evaluation. +- `organism.py`: organism runtime state and lineage event types. +- `environment.py`: food state and deterministic food creation. +- `spatial.py`: read-only start-of-tick proximity queries. +- `physics.py`: movement, energy charges, eating reach, and world wrapping. +- `evolution.py`: reproduction eligibility, inheritance, energy transfer, death. +- `simulation.py`: authoritative tick order and invariant enforcement. +- `schema.py` / `writer.py`: artifact contract and canonical JSON output. +- `runner.py`: config-to-simulation construction and bounded headless execution. +- `cli.py`: command parsing and user-facing exit codes. + +## Numeric and Performance Contract + +- `UNIT = 1024` represents one scalar unit. +- World positions and distances are integer milli-like units based on `UNIT`. +- Heading is one of 16 deterministic directions. +- Controller inputs, weights, biases, and outputs are integers. +- Energy is an integer; no authoritative state field is a float. +- Organism IDs and food IDs are monotonic integers. +- The smoke budget is 64 founders for 5,000 ticks in under 30 seconds and under + 512 MiB peak RSS on the current arm64 host with Python 3.12.11. +- CI runs correctness checks, not the wall-clock budget; the benchmark script + reports the local timing and fails only when explicitly passed `--enforce`. + +### Task 1: Reboot the repository skeleton and stage the approved cleanup + +**Files:** +- Create: `.github/workflows/ci.yml` +- Create: `.gitignore` +- Create: `AGENTS.md` +- Create: `ARCHITECTURE.md` +- Create: `CONTRIBUTING.md` +- Recreate: `LICENSE` +- Create: `Makefile` +- Create: `README.md` +- Create: `RUNNING.md` +- Create: `pyproject.toml` +- Create: `antelab/__init__.py` +- Create: `tests/__init__.py` +- Create: `tests/test_package.py` +- Generate: `uv.lock` +- Delete: all historical tracked paths listed by the approved reset boundary + +- [ ] **Step 1: Confirm the destructive boundary before creating files** + +Run: + +```bash +git status --short --branch +git diff --cached --name-status +git rev-parse HEAD +``` + +Expected: + +- `HEAD` is `f950c1463d97ac842808e7b6b9d70e08d739b3e4`; +- historical files appear as unstaged `D` entries; +- the cached diff is empty; +- the approved design and this plan exist. + +- [ ] **Step 2: Write the failing package identity test** + +Create `tests/__init__.py` as an empty file and create +`tests/test_package.py`: + +```python +from antelab import __version__ + + +def test_package_identity() -> None: + assert __version__ == "0.2.0" +``` + +- [ ] **Step 3: Run the test to verify the skeleton is absent** + +Run: + +```bash +python3 -m pytest tests/test_package.py -q +``` + +Expected: FAIL because the deleted historical package is unavailable or does not +expose version `0.2.0`. If the global interpreter lacks pytest, record that +environment failure and continue to Step 4; Step 7 is the authoritative red/green +verification in the new environment. + +- [ ] **Step 4: Create the minimal package and dependency contract** + +Create `antelab/__init__.py`: + +```python +"""AnteLab digital evolution laboratory.""" + +__version__ = "0.2.0" +``` + +Create `pyproject.toml`: + +```toml +[project] +name = "antelab" +version = "0.2.0" +description = "Deterministic digital evolution laboratory" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.12" +dependencies = ["pyyaml>=6.0.2,<7"] + +[project.scripts] +antelab = "antelab.cli:main" + +[project.optional-dependencies] +dev = [ + "mypy>=1.16,<2", + "pytest>=8.4,<9", + "ruff>=0.11,<1", + "types-PyYAML>=6.0.12,<7", +] + +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["A", "B", "E", "F", "I", "N", "SIM", "UP"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true +``` + +- [ ] **Step 5: Recreate the root project contract** + +Create `.gitignore`: + +```gitignore +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +coverage.xml +dist/ +build/ +artifacts/ +benchmarks/*.json +.DS_Store +.codex/ +.claude/ +.gstack/ +``` + +Recreate `LICENSE` with the unchanged MIT text and copyright line +`Copyright (c) 2026 AnteLab Contributors` from `f950c14^:LICENSE`. + +Create `README.md`: + +```markdown +# AnteLab + +> **No prompts. No goals. Just physics, mutation, and selection.** + +AnteLab is an open-source digital evolution laboratory. Its authoritative +headless engine starts from primitive inherited controllers and stable physics, +then records how populations change across generations. + +The reboot is under active construction. V1 is CPU-only, deterministic, and has +no LLM or network dependency. + +## Development + +See [RUNNING.md](RUNNING.md) for setup and commands. The approved architecture is +in [ARCHITECTURE.md](ARCHITECTURE.md). +``` + +Create `ARCHITECTURE.md`: + +```markdown +# Architecture + +The headless Python engine is authoritative. Organisms receive bounded local +sensors, emit bounded effectors, and inherit fixed-point genomes. Rendering and +analysis never feed state back into the engine. + +The implementation contract is defined by +`docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md`. +The first implementation slice is tracked by +`docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md`. +``` + +Create `RUNNING.md`: + +````markdown +# Running AnteLab + +Requirements: Python 3.12 and uv. + +```bash +make setup +make verify +``` + +After the kernel tasks land, run the smoke experiment with: + +```bash +uv run antelab run experiments/foraging-genesis.yaml --output artifacts/run.json +``` +```` + +Create `CONTRIBUTING.md`: + +```markdown +# Contributing + +Every behavior change starts with a failing test. Authoritative simulation code +must use integer state, AnteLab-owned RNG streams, and versioned serialization. +Do not introduce LLM or network dependencies into the core organism loop. + +Run `make verify` before submitting a change. Generated artifacts are ignored; +small deterministic fixtures may be committed with their reproduction command. +``` + +Create `AGENTS.md`: + +```markdown +# AnteLab Agent Rules + +- Read the approved reboot design and current implementation plan before edits. +- Preserve deterministic integer state and simulation-owned randomness. +- Do not add LLM calls, free-form actions, company simulation, or receipt product + behavior to the V1 core. +- Use tests first and explicit file staging; never use `git add .`. +- Treat extinction as a valid result and broken invariants as engine failures. +``` + +Create `Makefile`: + +```make +.PHONY: setup test lint typecheck verify + +setup: + uv sync --extra dev + +test: + uv run pytest -q + +lint: + uv run ruff check antelab tests scripts + +typecheck: + uv run mypy antelab scripts + +verify: lint typecheck test +``` + +Create `.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + kernel: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - run: uv sync --extra dev --frozen + - run: uv run ruff check antelab tests scripts + - run: uv run mypy antelab scripts + - run: uv run pytest -q +``` + +- [ ] **Step 6: Lock dependencies** + +Run: + +```bash +uv lock +uv sync --extra dev +``` + +Expected: `uv.lock` is created and the editable `antelab==0.2.0` package is +installed without FastAPI, Uvicorn, HTTPX, OpenAI, Anthropic, or frontend +dependencies. + +- [ ] **Step 7: Run the package test in the new environment** + +Run: + +```bash +uv run pytest tests/test_package.py -q +``` + +Expected: `1 passed`. + +- [ ] **Step 8: Stage only the replacement skeleton and approved retirements** + +Stage recreated and new files explicitly: + +```bash +git add .github/workflows/ci.yml .gitignore AGENTS.md ARCHITECTURE.md CONTRIBUTING.md LICENSE Makefile README.md RUNNING.md pyproject.toml uv.lock antelab/__init__.py tests/__init__.py tests/test_package.py +``` + +Stage updates/deletions only under the approved historical paths: + +```bash +git add -u -- .env.example .github/workflows/frontend-ci.yml CLAUDE.md CONSTITUTION.md DESIGN.md DISCOVERIES.md Dockerfile.backend EXPERIMENTS.md ROADMAP.md SPEC_STATUS.md antelab/api antelab/config antelab/engine antelab/experiments antelab/llm antelab/season_bundle.py cast docker-compose.yaml docs/README.md docs/plans docs/research experiments frontend prompts scripts seasons specs tests +``` + +Do not stage `docs/superpowers/`; the approved design and implementation plan are +already committed separately. + +- [ ] **Step 9: Audit the cleanup before committing** + +Run: + +```bash +git diff --cached --check +git diff --cached --name-status +git diff --cached --stat +git status --short --branch +``` + +Expected: + +- the cached diff contains the replacement skeleton and only the approved old + paths; +- `docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md` + and this plan are not deleted or modified; +- no untracked historical generated assets remain; +- the unstaged deletion list is empty after every historical path has been + either recreated or intentionally staged. + +- [ ] **Step 10: Commit the formal repository reboot** + +```bash +git commit -m "chore: reboot repository for digital evolution" +``` + +### Task 2: Add deterministic fixed-point geometry + +**Files:** +- Create: `antelab/core/__init__.py` +- Create: `antelab/core/fixed.py` +- Create: `tests/core/test_fixed.py` + +- [ ] **Step 1: Write fixed-point geometry tests** + +Create `tests/core/test_fixed.py`: + +```python +from antelab.core.fixed import ( + DIRECTION_COUNT, + UNIT, + clamp, + direction_for_delta, + heading_delta, + move_point, + normalize_ratio, + toroidal_delta, + toroidal_distance_sq, +) + + +def test_toroidal_delta_takes_shortest_signed_path() -> None: + assert toroidal_delta(95 * UNIT, 5 * UNIT, 100 * UNIT) == 10 * UNIT + assert toroidal_delta(5 * UNIT, 95 * UNIT, 100 * UNIT) == -10 * UNIT + + +def test_move_point_wraps_and_uses_discrete_heading() -> None: + x, y = move_point(99 * UNIT, 10 * UNIT, 0, 3 * UNIT, 100 * UNIT, 100 * UNIT) + assert (x, y) == (2 * UNIT, 10 * UNIT) + assert DIRECTION_COUNT == 16 + + +def test_direction_and_heading_error_use_integer_vectors() -> None: + assert direction_for_delta(10 * UNIT, UNIT) == 0 + assert direction_for_delta(UNIT, 10 * UNIT) == 4 + assert heading_delta(15, 1) == 2 + assert heading_delta(1, 15) == -2 + + +def test_distance_clamp_and_ratio_are_integer_only() -> None: + distance = toroidal_distance_sq(0, 0, 3 * UNIT, 4 * UNIT, 100 * UNIT, 100 * UNIT) + assert distance == 25 * UNIT * UNIT + assert clamp(12, 0, 10) == 10 + assert normalize_ratio(1, 4) == UNIT // 4 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +uv run pytest tests/core/test_fixed.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'antelab.core'`. + +- [ ] **Step 3: Implement the fixed-point module** + +Create an empty `antelab/core/__init__.py` and create +`antelab/core/fixed.py`: + +```python +"""Deterministic integer geometry for the authoritative simulation.""" + +UNIT = 1024 +DIRECTION_COUNT = 16 +DIRECTION_VECTORS: tuple[tuple[int, int], ...] = ( + (1024, 0), + (946, 392), + (724, 724), + (392, 946), + (0, 1024), + (-392, 946), + (-724, 724), + (-946, 392), + (-1024, 0), + (-946, -392), + (-724, -724), + (-392, -946), + (0, -1024), + (392, -946), + (724, -724), + (946, -392), +) + + +def clamp(value: int, lower: int, upper: int) -> int: + return max(lower, min(upper, value)) + + +def normalize_ratio(numerator: int, denominator: int) -> int: + if denominator <= 0: + raise ValueError("denominator must be positive") + return clamp((numerator * UNIT) // denominator, -UNIT, UNIT) + + +def toroidal_delta(start: int, end: int, size: int) -> int: + if size <= 0: + raise ValueError("world size must be positive") + delta = (end - start) % size + if delta > size // 2: + delta -= size + return delta + + +def toroidal_distance_sq( + ax: int, + ay: int, + bx: int, + by: int, + width: int, + height: int, +) -> int: + dx = toroidal_delta(ax, bx, width) + dy = toroidal_delta(ay, by, height) + return dx * dx + dy * dy + + +def direction_for_delta(dx: int, dy: int) -> int: + if dx == 0 and dy == 0: + return 0 + return max( + range(DIRECTION_COUNT), + key=lambda index: ( + dx * DIRECTION_VECTORS[index][0] + dy * DIRECTION_VECTORS[index][1], + -index, + ), + ) + + +def heading_delta(start: int, end: int) -> int: + delta = (end - start) % DIRECTION_COUNT + if delta > DIRECTION_COUNT // 2: + delta -= DIRECTION_COUNT + return delta + + +def move_point( + x: int, + y: int, + heading: int, + distance: int, + width: int, + height: int, +) -> tuple[int, int]: + dx, dy = DIRECTION_VECTORS[heading % DIRECTION_COUNT] + return ( + (x + (dx * distance) // UNIT) % width, + (y + (dy * distance) // UNIT) % height, + ) +``` + +- [ ] **Step 4: Run focused tests and static checks** + +Run: + +```bash +uv run pytest tests/core/test_fixed.py -q +uv run ruff check antelab/core/fixed.py tests/core/test_fixed.py +uv run mypy antelab/core/fixed.py +``` + +Expected: 4 fixed-geometry tests pass; Ruff and mypy exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add antelab/core/__init__.py antelab/core/fixed.py tests/core/test_fixed.py +git commit -m "feat: add deterministic fixed-point geometry" +``` + +### Task 3: Add owned random streams and validated YAML configuration + +**Files:** +- Create: `antelab/core/rng.py` +- Create: `antelab/core/config.py` +- Create: `tests/core/test_rng.py` +- Create: `tests/core/test_config.py` + +- [ ] **Step 1: Write deterministic RNG tests** + +Create `tests/core/test_rng.py`: + +```python +from antelab.core.rng import Rng, stable_rank + + +def test_splitmix64_reference_sequence() -> None: + rng = Rng(0) + assert [rng.next_u64() for _ in range(3)] == [ + 0xE220A8397B1DCDAF, + 0x6E789E6AA1B965F4, + 0x06C45D188009454F, + ] + + +def test_randbelow_and_signed_delta_are_repeatable() -> None: + left = Rng(42) + right = Rng(42) + assert [left.randbelow(17) for _ in range(20)] == [right.randbelow(17) for _ in range(20)] + assert [left.signed_delta(9) for _ in range(20)] == [right.signed_delta(9) for _ in range(20)] + + +def test_stable_rank_is_keyed_and_order_sensitive() -> None: + assert stable_rank(42, 7, 3, 11) == stable_rank(42, 7, 3, 11) + assert stable_rank(42, 7, 3, 11) != stable_rank(42, 7, 11, 3) +``` + +- [ ] **Step 2: Implement the repository-owned RNG** + +Create `antelab/core/rng.py`: + +```python +"""Versioned integer-only random streams.""" + +from hashlib import blake2b + +_MASK_64 = (1 << 64) - 1 + + +class Rng: + def __init__(self, seed: int) -> None: + self._state = seed & _MASK_64 + + @property + def state(self) -> int: + return self._state + + def next_u64(self) -> int: + self._state = (self._state + 0x9E3779B97F4A7C15) & _MASK_64 + value = self._state + value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & _MASK_64 + value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & _MASK_64 + return (value ^ (value >> 31)) & _MASK_64 + + def randbelow(self, upper: int) -> int: + if not 0 < upper <= 1 << 64: + raise ValueError("upper must be within [1, 2**64]") + limit = (1 << 64) - ((1 << 64) % upper) + while True: + value = self.next_u64() + if value < limit: + return value % upper + + def chance(self, numerator: int, denominator: int) -> bool: + if denominator <= 0 or not 0 <= numerator <= denominator: + raise ValueError("chance must satisfy 0 <= numerator <= denominator") + return self.randbelow(denominator) < numerator + + def signed_delta(self, magnitude: int) -> int: + if magnitude < 0: + raise ValueError("magnitude must be non-negative") + return self.randbelow(2 * magnitude + 1) - magnitude + + +def stable_rank(*parts: int) -> int: + payload = b"|".join(str(part).encode("ascii") for part in parts) + return int.from_bytes(blake2b(payload, digest_size=8).digest(), "big") +``` + +- [ ] **Step 3: Write configuration parsing and validation tests** + +Create `tests/core/test_config.py`: + +```python +from dataclasses import replace +from pathlib import Path + +import pytest + +from antelab.core.config import ConfigError, load_config + + +def test_load_config_normalizes_integer_contract(tmp_path: Path) -> None: + path = tmp_path / "experiment.yaml" + path.write_text( + """schema_version: 1 +condition: uncontrolled_baseline +seed: 42 +ticks: 100 +world: + width: 204800 + height: 204800 + initial_food: 80 + max_food: 120 + food_energy: 4096 + food_regen_per_tick: 1 + max_entities: 256 +life: + initial_population: 16 + initial_energy: 8192 + max_energy: 16384 + max_age: 2000 + reproduction_cooldown: 40 +mutation: + probability: 64 + step: 32 +""", + encoding="utf-8", + ) + config = load_config(path) + assert config.seed == 42 + assert config.condition == "uncontrolled_baseline" + assert config.world.width == 204800 + assert config.mutation.probability == 64 + assert config.to_dict()["schema_version"] == 1 + + +def test_load_config_rejects_population_over_capacity(tmp_path: Path) -> None: + path = tmp_path / "invalid.yaml" + path.write_text( + """schema_version: 1 +condition: uncontrolled_baseline +seed: 1 +ticks: 1 +world: + width: 1024 + height: 1024 + initial_food: 0 + max_food: 0 + food_energy: 1024 + food_regen_per_tick: 0 + max_entities: 2 +life: + initial_population: 3 + initial_energy: 1024 + max_energy: 2048 + max_age: 10 + reproduction_cooldown: 1 +mutation: {probability: 0, step: 0} +""", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="initial_population cannot exceed max_entities"): + load_config(path) +``` + +- [ ] **Step 4: Implement immutable configuration types** + +Create `antelab/core/config.py` with these frozen dataclasses and helpers: + +```python +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import cast + +import yaml + +from antelab.core.fixed import UNIT + + +class ConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class WorldConfig: + width: int + height: int + initial_food: int + max_food: int + food_energy: int + food_regen_per_tick: int + max_entities: int + + +@dataclass(frozen=True) +class LifeConfig: + initial_population: int + initial_energy: int + max_energy: int + max_age: int + reproduction_cooldown: int + + +@dataclass(frozen=True) +class MutationConfig: + probability: int + step: int + + +@dataclass(frozen=True) +class SimulationConfig: + schema_version: int + condition: str + seed: int + ticks: int + world: WorldConfig + life: LifeConfig + mutation: MutationConfig + + @classmethod + def from_mapping(cls, raw: dict[str, object]) -> "SimulationConfig": + _require_exact_keys( + raw, + {"schema_version", "condition", "seed", "ticks", "world", "life", "mutation"}, + "configuration", + ) + world = _mapping(raw, "world") + life = _mapping(raw, "life") + mutation = _mapping(raw, "mutation") + _require_exact_keys( + world, + { + "width", + "height", + "initial_food", + "max_food", + "food_energy", + "food_regen_per_tick", + "max_entities", + }, + "world", + ) + _require_exact_keys( + life, + { + "initial_population", + "initial_energy", + "max_energy", + "max_age", + "reproduction_cooldown", + }, + "life", + ) + _require_exact_keys(mutation, {"probability", "step"}, "mutation") + return cls( + schema_version=_integer(raw, "schema_version", 1), + condition=_string(raw, "condition"), + seed=_integer(raw, "seed"), + ticks=_integer(raw, "ticks", 1), + world=WorldConfig( + width=_integer(world, "width", 1), + height=_integer(world, "height", 1), + initial_food=_integer(world, "initial_food"), + max_food=_integer(world, "max_food"), + food_energy=_integer(world, "food_energy", 1), + food_regen_per_tick=_integer(world, "food_regen_per_tick"), + max_entities=_integer(world, "max_entities", 1), + ), + life=LifeConfig( + initial_population=_integer(life, "initial_population", 1), + initial_energy=_integer(life, "initial_energy", 1), + max_energy=_integer(life, "max_energy", 1), + max_age=_integer(life, "max_age", 1), + reproduction_cooldown=_integer(life, "reproduction_cooldown"), + ), + mutation=MutationConfig( + probability=_integer(mutation, "probability"), + step=_integer(mutation, "step"), + ), + ) + + def validate(self) -> None: + if self.schema_version != 1: + raise ConfigError("schema_version must equal 1") + if self.condition != "uncontrolled_baseline": + raise ConfigError("V1 condition must equal uncontrolled_baseline") + if self.seed > (1 << 64) - 1: + raise ConfigError("seed must fit an unsigned 64-bit integer") + if self.world.width > 1 << 64 or self.world.height > 1 << 64: + raise ConfigError("world dimensions must not exceed 2**64") + if self.world.initial_food > self.world.max_food: + raise ConfigError("initial_food cannot exceed max_food") + if self.life.initial_population > self.world.max_entities: + raise ConfigError("initial_population cannot exceed max_entities") + if self.life.initial_energy > self.life.max_energy: + raise ConfigError("initial_energy cannot exceed max_energy") + if self.mutation.probability > UNIT: + raise ConfigError("mutation probability cannot exceed UNIT") + if self.mutation.step > 4 * UNIT: + raise ConfigError("mutation step cannot exceed 4 * UNIT") + + def to_dict(self) -> dict[str, object]: + return cast(dict[str, object], asdict(self)) + + +def _mapping(mapping: dict[str, object], key: str) -> dict[str, object]: + value = mapping.get(key) + if not isinstance(value, dict) or not all(isinstance(item, str) for item in value): + raise ConfigError(f"{key} must be a string-keyed mapping") + return cast(dict[str, object], value) + + +def _require_exact_keys( + mapping: dict[str, object], + expected: set[str], + label: str, +) -> None: + actual = set(mapping) + if actual != expected: + raise ConfigError( + f"{label} keys differ: missing={sorted(expected - actual)}, " + f"extra={sorted(actual - expected)}" + ) + + +def _string(mapping: dict[str, object], key: str) -> str: + value = mapping.get(key) + if not isinstance(value, str) or not value: + raise ConfigError(f"{key} must be a non-empty string") + return value + + +def _integer(mapping: dict[str, object], key: str, minimum: int = 0) -> int: + value = mapping.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise ConfigError(f"{key} must be an integer >= {minimum}") + return value + + +def load_config(path: Path) -> SimulationConfig: + loaded: object = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(loaded, dict) or not all(isinstance(item, str) for item in loaded): + raise ConfigError("configuration root must be a mapping") + raw = cast(dict[str, object], loaded) + config = SimulationConfig.from_mapping(raw) + config.validate() + return config +``` + +The exact-key gates reject misspelled or silently ignored configuration. The +`_integer` calls enforce positive ticks, dimensions, energy, maximum age, +and maximum entities, plus non-negative regeneration, cooldown, seed, mutation +probability, and mutation step. Add one focused test for each cross-field branch +inside `validate()` so no validation exists only as unexecuted code. + +- [ ] **Step 5: Run the focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_rng.py tests/core/test_config.py -q +uv run ruff check antelab/core/rng.py antelab/core/config.py tests/core/test_rng.py tests/core/test_config.py +uv run mypy antelab/core/rng.py antelab/core/config.py +``` + +Expected: all RNG/config tests pass, including every validation branch; Ruff and +mypy exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add antelab/core/rng.py antelab/core/config.py tests/core/test_rng.py tests/core/test_config.py +git commit -m "feat: add deterministic config and random streams" +``` + +### Task 4: Add canonical genomes and deterministic mutation + +**Files:** +- Create: `antelab/core/genome.py` +- Create: `tests/core/test_genome.py` + +- [ ] **Step 1: Write genome contract tests** + +Create `tests/core/test_genome.py`: + +```python +from antelab.core.fixed import UNIT +from antelab.core.genome import CONTROLLER_GENE_COUNT, Genome, mutate_genome, primitive_ancestor +from antelab.core.rng import Rng + + +def test_primitive_genome_has_canonical_round_trip() -> None: + genome = primitive_ancestor() + assert Genome.from_dict(genome.to_dict()) == genome + assert len(genome.controller_weights) + len(genome.controller_biases) == CONTROLLER_GENE_COUNT + + +def test_zero_probability_preserves_parent() -> None: + parent = primitive_ancestor() + assert mutate_genome(parent, Rng(7), probability=0, step=64) == parent + + +def test_full_probability_is_repeatable_and_bounded() -> None: + parent = primitive_ancestor() + left = mutate_genome(parent, Rng(99), probability=UNIT, step=128) + right = mutate_genome(parent, Rng(99), probability=UNIT, step=128) + assert left == right + assert left != parent + left.validate() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +uv run pytest tests/core/test_genome.py -q +``` + +Expected: FAIL because `antelab.core.genome` does not exist. + +- [ ] **Step 3: Implement the genome schema** + +Create `antelab/core/genome.py` with these constants and immutable fields: + +```python +INPUT_COUNT = 10 +OUTPUT_COUNT = 5 +CONTROLLER_WEIGHT_COUNT = INPUT_COUNT * OUTPUT_COUNT +CONTROLLER_BIAS_COUNT = OUTPUT_COUNT +CONTROLLER_GENE_COUNT = CONTROLLER_WEIGHT_COUNT + CONTROLLER_BIAS_COUNT + + +@dataclass(frozen=True) +class Genome: + body_radius: int + max_thrust: int + turn_steps: int + sensor_cost: int + basal_cost: int + move_cost: int + sensor_range: int + field_of_view: int + reproduction_threshold: int + reproduction_allocation: int + reproduction_cost: int + signal_strength: int + signal_cost: int + color_r: int + color_g: int + color_b: int + controller_weights: tuple[int, ...] + controller_biases: tuple[int, ...] +``` + +Use a module-level `TRAIT_LIMITS` mapping with these inclusive ranges: + +```python +TRAIT_LIMITS = { + "body_radius": (UNIT // 2, 4 * UNIT), + "max_thrust": (UNIT // 8, 4 * UNIT), + "turn_steps": (1, 4), + "sensor_cost": (0, UNIT), + "basal_cost": (1, UNIT), + "move_cost": (0, UNIT), + "sensor_range": (4 * UNIT, 64 * UNIT), + "field_of_view": (1, 8), + "reproduction_threshold": (4 * UNIT, 64 * UNIT), + "reproduction_allocation": (UNIT, 32 * UNIT), + "reproduction_cost": (0, 4 * UNIT), + "signal_strength": (0, UNIT), + "signal_cost": (0, UNIT), + "color_r": (0, 255), + "color_g": (0, 255), + "color_b": (0, 255), +} +``` + +`Genome.validate()` must enforce every scalar range, exactly 50 weights, exactly +5 biases, and controller genes in `[-4 * UNIT, 4 * UNIT]`. + +Use these sensor/output indices and exact ancestor factory: + +```python +OWN_ENERGY_INPUT = 0 +FOOD_BEARING_INPUT = 3 +TURN_OUTPUT = 0 +THRUST_OUTPUT = 1 +EAT_OUTPUT = 2 +SIGNAL_OUTPUT = 3 +REPRODUCE_OUTPUT = 4 + + +def primitive_ancestor() -> Genome: + weights = [0] * CONTROLLER_WEIGHT_COUNT + biases = [0] * CONTROLLER_BIAS_COUNT + weights[TURN_OUTPUT * INPUT_COUNT + FOOD_BEARING_INPUT] = UNIT + weights[REPRODUCE_OUTPUT * INPUT_COUNT + OWN_ENERGY_INPUT] = UNIT + biases[THRUST_OUTPUT] = UNIT // 2 + biases[EAT_OUTPUT] = UNIT + biases[SIGNAL_OUTPUT] = 0 + biases[REPRODUCE_OUTPUT] = -(3 * UNIT) // 4 + genome = Genome( + body_radius=UNIT, + max_thrust=UNIT, + turn_steps=1, + sensor_cost=2, + basal_cost=8, + move_cost=16, + sensor_range=24 * UNIT, + field_of_view=8, + reproduction_threshold=18 * UNIT, + reproduction_allocation=6 * UNIT, + reproduction_cost=128, + signal_strength=UNIT // 2, + signal_cost=4, + color_r=80, + color_g=210, + color_b=140, + controller_weights=tuple(weights), + controller_biases=tuple(biases), + ) + genome.validate() + return genome +``` + +This ancestor has a minimal food-bearing reflex, continuous forward pressure, +unconditional eat attempts, and energy-gated reproduction. It is an explicit +starting condition, not evidence that evolution has already occurred. + +`mutate_genome()` must iterate scalar fields in declared order, then controller +weights, then biases. For each gene, call `rng.chance(probability, UNIT)` and add +`rng.signed_delta(step)` when selected, clamping to that gene's bounds. + +Use explicit `to_dict()` and `from_dict()` methods. Reject missing, extra, bool, +float, or incorrectly sized values with `ValueError`. `canonical_key()` returns +`json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"), +allow_nan=False)` and is the sole grouping/sorting key for genome distributions. + +- [ ] **Step 4: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_genome.py -q +uv run ruff check antelab/core/genome.py tests/core/test_genome.py +uv run mypy antelab/core/genome.py +``` + +Expected: 3 tests pass; Ruff and mypy exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add antelab/core/genome.py tests/core/test_genome.py +git commit -m "feat: add inherited fixed-point genomes" +``` + +### Task 5: Add the bounded sensor-to-effector controller + +**Files:** +- Create: `antelab/core/brain.py` +- Create: `tests/core/test_brain.py` + +- [ ] **Step 1: Write controller tests** + +Create `tests/core/test_brain.py`: + +```python +import pytest + +from antelab.core.brain import EffectorFrame, SensorFrame, evaluate +from antelab.core.fixed import UNIT +from antelab.core.genome import primitive_ancestor + + +def test_controller_turns_toward_food_and_attempts_eating() -> None: + sensors = SensorFrame( + own_energy=UNIT, + own_age=0, + food_distance=UNIT // 4, + food_bearing=UNIT // 2, + food_density=UNIT // 2, + organism_distance=UNIT, + organism_bearing=0, + organism_signal=0, + organism_density=0, + bias=UNIT, + ) + effectors = evaluate(primitive_ancestor(), sensors) + assert effectors.turn > 0 + assert effectors.thrust > 0 + assert effectors.eat + + +def test_sensor_frame_rejects_values_outside_fixed_range() -> None: + with pytest.raises(ValueError, match="sensor values"): + SensorFrame(2 * UNIT, 0, 0, 0, 0, 0, 0, 0, 0, UNIT) + + +def test_effectors_are_bounded() -> None: + frame = EffectorFrame(turn=-UNIT, thrust=UNIT, eat=True, signal=0, reproduce=False) + frame.validate() +``` + +- [ ] **Step 2: Implement sensor, effector, and controller evaluation** + +Create `antelab/core/brain.py`: + +```python +@dataclass(frozen=True) +class SensorFrame: + own_energy: int + own_age: int + food_distance: int + food_bearing: int + food_density: int + organism_distance: int + organism_bearing: int + organism_signal: int + organism_density: int + bias: int + + def __post_init__(self) -> None: + if any(not -UNIT <= value <= UNIT for value in self.values()): + raise ValueError("sensor values must be within [-UNIT, UNIT]") + + def values(self) -> tuple[int, ...]: + return tuple(getattr(self, item.name) for item in fields(self)) + + +@dataclass(frozen=True) +class EffectorFrame: + turn: int + thrust: int + eat: bool + signal: int + reproduce: bool + + def validate(self) -> None: + if not -UNIT <= self.turn <= UNIT: + raise ValueError("turn out of range") + if not 0 <= self.thrust <= UNIT or not 0 <= self.signal <= UNIT: + raise ValueError("non-negative effector out of range") +``` + +Implement `evaluate(genome, sensors)` as a row-major 10-to-5 integer linear +controller. For each output, calculate: + +```python +total = genome.controller_biases[output_index] * UNIT +for input_index, sensor in enumerate(sensors.values()): + total += sensor * genome.controller_weights[output_index * INPUT_COUNT + input_index] +activated = clamp(total // UNIT, -UNIT, UNIT) +``` + +Map outputs to effectors as follows: + +- output 0: signed turn; +- output 1: thrust clamped to `[0, UNIT]`; +- output 2: eat when positive; +- output 3: signal clamped to `[0, UNIT]`; +- output 4: reproduce when positive. + +- [ ] **Step 3: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_brain.py -q +uv run ruff check antelab/core/brain.py tests/core/test_brain.py +uv run mypy antelab/core/brain.py +``` + +Expected: 3 tests pass; Ruff and mypy exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add antelab/core/brain.py tests/core/test_brain.py +git commit -m "feat: add bounded organism controller" +``` + +### Task 6: Add organism, food, and spatial snapshot types + +**Files:** +- Create: `antelab/core/organism.py` +- Create: `antelab/core/environment.py` +- Create: `antelab/core/spatial.py` +- Create: `tests/helpers.py` +- Create: `tests/core/test_environment.py` +- Create: `tests/core/test_spatial.py` + +- [ ] **Step 1: Write environment and spatial tests** + +Create tests that assert: + +```python +def test_food_spawning_is_deterministic() -> None: + left = Environment.empty(width=100 * UNIT, height=100 * UNIT) + right = Environment.empty(width=100 * UNIT, height=100 * UNIT) + left.spawn_food(Rng(5), count=4, energy=2 * UNIT) + right.spawn_food(Rng(5), count=4, energy=2 * UNIT) + assert left.to_dict() == right.to_dict() + + +def test_spatial_snapshot_finds_nearest_across_wrap() -> None: + food = Food(id=1, x=99 * UNIT, y=5 * UNIT, energy=UNIT) + index = SpatialSnapshot.build( + organisms=(), + foods=(food,), + width=100 * UNIT, + height=100 * UNIT, + ) + assert index.nearest_food(1 * UNIT, 5 * UNIT, 4 * UNIT) == food + + +def test_organism_query_excludes_the_observer_itself() -> None: + observer = make_organism(id=1, x=5 * UNIT, y=5 * UNIT) + neighbor = make_organism(id=2, x=6 * UNIT, y=5 * UNIT) + index = SpatialSnapshot.build( + organisms=(observer, neighbor), + foods=(), + width=100 * UNIT, + height=100 * UNIT, + ) + assert index.nearest_organism( + observer.x, + observer.y, + 4 * UNIT, + exclude_id=observer.id, + ) == neighbor +``` + +- [ ] **Step 2: Implement runtime dataclasses** + +In `organism.py`, define these types: + +```python +@dataclass +class Organism: + id: int + parent_id: int | None + generation: int + genome: Genome + x: int + y: int + heading: int + energy: int + age: int = 0 + alive: bool = True + last_reproduction_tick: int = -1_000_000 + signal: int = 0 + food_eaten: int = 0 + offspring_count: int = 0 + death_cause: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "parent_id": self.parent_id, + "generation": self.generation, + "genome": self.genome.to_dict(), + "x": self.x, + "y": self.y, + "heading": self.heading, + "energy": self.energy, + "age": self.age, + "alive": self.alive, + "last_reproduction_tick": self.last_reproduction_tick, + "signal": self.signal, + "food_eaten": self.food_eaten, + "offspring_count": self.offspring_count, + "death_cause": self.death_cause, + } + + +@dataclass(frozen=True) +class LineageEvent: + parent_id: int + child_id: int + tick: int + generation: int +``` + +In `environment.py`, define: + +```python +@dataclass(frozen=True) +class Food: + id: int + x: int + y: int + energy: int + + +@dataclass +class Environment: + width: int + height: int + foods: dict[int, Food] + next_food_id: int +``` + +Add `Food.to_dict()` and `Environment.to_dict()` with sorted food IDs and only +plain integer/list/dict values. + +```python +@classmethod +def empty(cls, width: int, height: int) -> Environment: + return cls(width=width, height=height, foods={}, next_food_id=1) + +def spawn_food(self, rng: Rng, count: int, energy: int) -> tuple[Food, ...]: + created: list[Food] = [] + for _ in range(count): + food = Food( + id=self.next_food_id, + x=rng.randbelow(self.width), + y=rng.randbelow(self.height), + energy=energy, + ) + self.foods[food.id] = food + self.next_food_id += 1 + created.append(food) + return tuple(created) +``` + +In `spatial.py`, create immutable `SpatialSnapshot` from tuples sorted by ID and +index them into toroidally wrapped uniform-grid cells of size `16 * UNIT`. +Cell counts use ceiling division so worlds need not be multiples of cell size. +`build()` stores separate mappings from `(cell_x, cell_y)` to sorted organism and +food tuples. Query methods enumerate only the wrapped cells intersecting the +requested radius, deduplicate wrapped cell coordinates, and then apply exact +toroidal distance checks. Expose `foods_within(x, y, radius)` and +`organisms_within(x, y, radius, exclude_id)` as ID-sorted candidate tuples; +build `nearest_food`, `nearest_organism`, `food_density`, and +`organism_density` on those methods. Organism queries require `exclude_id`, and +both nearest and density paths must exclude that identity. Break equal-distance +nearest-query ties by lower entity ID; this ordering is for read-only identity +and is not an action-resolution priority. + +Create `tests/helpers.py` so later tests do not invent incompatible organism +fixtures: + +```python +from antelab.core.config import LifeConfig, MutationConfig, SimulationConfig, WorldConfig +from antelab.core.fixed import UNIT +from antelab.core.genome import Genome, primitive_ancestor +from antelab.core.organism import Organism + + +def make_organism( + *, + id: int = 1, + parent_id: int | None = None, + generation: int = 0, + genome: Genome | None = None, + x: int = 0, + y: int = 0, + heading: int = 0, + energy: int = 20_000, + age: int = 0, + alive: bool = True, + last_reproduction_tick: int = -1_000_000, +) -> Organism: + return Organism( + id=id, + parent_id=parent_id, + generation=generation, + genome=genome or primitive_ancestor(), + x=x, + y=y, + heading=heading, + energy=energy, + age=age, + alive=alive, + last_reproduction_tick=last_reproduction_tick, + ) + + +def make_config( + *, + seed: int = 42, + initial_population: int = 4, + max_entities: int = 32, +) -> SimulationConfig: + return SimulationConfig( + schema_version=1, + condition="uncontrolled_baseline", + seed=seed, + ticks=200, + world=WorldConfig( + width=100 * UNIT, + height=100 * UNIT, + initial_food=20, + max_food=40, + food_energy=2 * UNIT, + food_regen_per_tick=1, + max_entities=max_entities, + ), + life=LifeConfig( + initial_population=initial_population, + initial_energy=12 * UNIT, + max_energy=32 * UNIT, + max_age=500, + reproduction_cooldown=10, + ), + mutation=MutationConfig(probability=64, step=32), + ) +``` + +- [ ] **Step 3: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_environment.py tests/core/test_spatial.py -q +uv run ruff check antelab/core/organism.py antelab/core/environment.py antelab/core/spatial.py tests/core/test_environment.py tests/core/test_spatial.py +uv run mypy antelab/core/organism.py antelab/core/environment.py antelab/core/spatial.py +``` + +Expected: all focused tests pass; Ruff and mypy exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add antelab/core/organism.py antelab/core/environment.py antelab/core/spatial.py tests/helpers.py tests/core/test_environment.py tests/core/test_spatial.py +git commit -m "feat: add organism world snapshot types" +``` + +### Task 7: Add fixed-point movement, eating, and energy accounting + +**Files:** +- Create: `antelab/core/physics.py` +- Create: `tests/core/test_physics.py` + +- [ ] **Step 1: Write physics tests** + +Cover these exact behaviors: + +```python +from antelab.core.environment import Food +from antelab.core.fixed import UNIT +from antelab.core.physics import ( + ActionCosts, + apply_movement, + can_eat, + charge_action_energy, + consume_food, +) +from tests.helpers import make_organism + + +def test_movement_wraps_and_action_charge_reports_actual_energy() -> None: + organism = make_organism(x=99 * UNIT, y=10 * UNIT, energy=10 * UNIT) + movement = apply_movement( + organism, + turn=0, + thrust=UNIT, + width=100 * UNIT, + height=100 * UNIT, + ) + assert movement.organism.x < 4 * UNIT + assert movement.organism.energy == organism.energy + assert movement.movement_energy_requested == organism.genome.move_cost + + charged = charge_action_energy( + movement.organism, + ActionCosts( + basal=organism.genome.basal_cost, + sensing=organism.genome.sensor_cost, + movement=movement.movement_energy_requested, + signal=0, + ), + ) + assert charged.organism.energy == ( + organism.energy + - charged.basal_energy_spent + - charged.sensing_energy_spent + - charged.movement_energy_spent + - charged.signal_energy_spent + ) + + +def test_eat_requires_reach_and_transfers_food_energy() -> None: + organism = make_organism(x=10 * UNIT, y=10 * UNIT, energy=4 * UNIT) + food = Food(id=1, x=10 * UNIT + UNIT, y=10 * UNIT, energy=2 * UNIT) + assert can_eat(organism, food, width=100 * UNIT, height=100 * UNIT) + outcome = consume_food(organism, food, max_energy=12 * UNIT) + assert outcome.organism.energy == 6 * UNIT + assert outcome.organism.food_eaten == 1 + assert outcome.absorbed_energy == 2 * UNIT + assert outcome.overflow_energy == 0 + + +def test_eating_reports_energy_that_cannot_be_absorbed() -> None: + organism = make_organism(energy=11 * UNIT) + food = Food(id=1, x=0, y=0, energy=2 * UNIT) + outcome = consume_food(organism, food, max_energy=12 * UNIT) + assert outcome.absorbed_energy == UNIT + assert outcome.overflow_energy == UNIT + assert outcome.absorbed_energy + outcome.overflow_energy == food.energy +``` + +- [ ] **Step 2: Implement pure physics transformations** + +`physics.py` must define immutable `MovementOutcome(organism, +movement_energy_requested)`, `ActionCosts(basal, sensing, movement, signal)`, +`ActionChargeOutcome(organism, basal_energy_spent, sensing_energy_spent, +movement_energy_spent, signal_energy_spent)`, and +`ConsumptionOutcome(organism, absorbed_energy, overflow_energy)`, then expose: + +```python +def apply_movement( + organism: Organism, + turn: int, + thrust: int, + width: int, + height: int, +) -> MovementOutcome: + turn_delta = 0 + if turn > UNIT // 4: + turn_delta = organism.genome.turn_steps + elif turn < -(UNIT // 4): + turn_delta = -organism.genome.turn_steps + heading = (organism.heading + turn_delta) % DIRECTION_COUNT + distance = (organism.genome.max_thrust * max(0, thrust)) // UNIT + x, y = move_point(organism.x, organism.y, heading, distance, width, height) + move_charge = (organism.genome.move_cost * max(0, thrust)) // UNIT + moved = replace( + organism, + x=x, + y=y, + heading=heading, + age=organism.age + 1, + ) + return MovementOutcome(moved, move_charge) +``` + +Implement `charge_action_energy()` after movement, eating, and signal resolution. +Debit in the versioned order basal, sensing, movement, then signal. Each actual +debit is `min(remaining_energy, requested_cost)`; return all four actual debits +and never allow negative energy. This makes the controller's action a deferred +energy obligation within the same tick: food acquired that tick may pay it, and +an organism unable to pay the full obligation reaches zero energy and dies in +the death phase. + +`can_eat` with reach equal to `body_radius + UNIT`. `consume_food` transfers up +to `max_energy`, reports any unabsorbed food energy as `overflow_energy`, and +increments `food_eaten`. Neither function mutates the input object. Overflow is +a declared environmental dissipation term; it must appear in the tick energy +ledger rather than disappearing silently. Ledger fields always use actual +debits reported by `ActionChargeOutcome`, not requested costs, so low-energy +clamping cannot create an unexplained gap. + +- [ ] **Step 3: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_physics.py -q +uv run ruff check antelab/core/physics.py tests/core/test_physics.py +uv run mypy antelab/core/physics.py +``` + +Expected: focused tests pass; Ruff and mypy exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add antelab/core/physics.py tests/core/test_physics.py +git commit -m "feat: add movement and energy physics" +``` + +### Task 8: Add inheritance, reproduction, and death + +**Files:** +- Create: `antelab/core/evolution.py` +- Create: `tests/core/test_evolution.py` + +- [ ] **Step 1: Write evolution tests** + +Tests must prove: + +```python +from antelab.core.evolution import can_reproduce, reproduce, resolve_death +from antelab.core.fixed import UNIT +from antelab.core.organism import LineageEvent +from antelab.core.rng import Rng +from tests.helpers import make_organism + + +def test_reproduction_transfers_energy_and_records_parentage() -> None: + parent = make_organism(id=7, generation=3, energy=20 * UNIT) + outcome = reproduce( + parent=parent, + child_id=8, + tick=100, + rng=Rng(12), + mutation_probability=0, + mutation_step=0, + width=100 * UNIT, + height=100 * UNIT, + ) + assert outcome.parent.energy + outcome.child.energy == ( + parent.energy - parent.genome.reproduction_cost + ) + assert outcome.energy_spent == parent.genome.reproduction_cost + assert outcome.child.parent_id == 7 + assert outcome.child.generation == 4 + assert outcome.child.genome == parent.genome + assert outcome.lineage == LineageEvent(7, 8, 100, 4) + + +def test_reproduction_respects_threshold_and_cooldown() -> None: + parent = make_organism(energy=UNIT, last_reproduction_tick=95) + assert not can_reproduce(parent, tick=100, cooldown=10) + + +def test_death_is_a_state_transition_not_an_exception() -> None: + organism = make_organism(energy=0, age=10) + dead = resolve_death(organism, max_age=100) + assert not dead.alive + assert dead.death_cause == "energy_depleted" +``` + +- [ ] **Step 2: Implement evolution transitions** + +Define immutable `ReproductionOutcome(parent, child, lineage, energy_spent)`. +Implement: + +- `can_reproduce`: alive, energy at or above genome threshold, enough energy for + `reproduction_cost + reproduction_allocation`, and cooldown met; +- `reproduce`: dissipate exactly `genome.reproduction_cost`, transfer exactly + `genome.reproduction_allocation` energy to the child, mutate a copied genome, + create a deterministic nearby position using the supplied RNG, update parent + offspring count and last reproduction tick, and report the dissipated cost; +- `resolve_death`: mark death from zero energy before maximum age; otherwise mark + `maximum_age` when `age >= max_age`. + +Reject cost plus allocation that would leave the parent negative with +`ValueError`; this is a broken caller invariant, not a normal failed +reproduction. + +- [ ] **Step 3: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_evolution.py -q +uv run ruff check antelab/core/evolution.py tests/core/test_evolution.py +uv run mypy antelab/core/evolution.py +``` + +Expected: focused tests pass; Ruff and mypy exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add antelab/core/evolution.py tests/core/test_evolution.py +git commit -m "feat: add heritable reproduction and death" +``` + +### Task 9: Build deterministic initialization and sensor snapshots + +**Files:** +- Create: `antelab/core/simulation.py` +- Create: `tests/core/test_simulation.py` + +- [ ] **Step 1: Write initialization and sensor tests** + +Start `tests/core/test_simulation.py` with one shared config factory: + +```python +from dataclasses import replace + +import pytest + +from antelab.core.config import SimulationConfig +from antelab.core.environment import Food +from antelab.core.fixed import UNIT +from antelab.core.simulation import Simulation +from tests.helpers import make_config, make_organism + + +@pytest.fixture +def config() -> SimulationConfig: + return make_config() + + +def test_initialization_is_seed_deterministic(config: SimulationConfig) -> None: + left = Simulation.create(config) + right = Simulation.create(config) + assert left.state_checksum() == right.state_checksum() + assert left.rng.state == right.rng.state + assert all(item.parent_id is None and item.generation == 0 for item in left.organisms.values()) + + +def test_different_seed_changes_initial_state(config: SimulationConfig) -> None: + left = Simulation.create(config) + right = Simulation.create(replace(config, seed=43)) + assert left.state_checksum() != right.state_checksum() + + +def test_sensor_frame_is_local_and_bounded(config: SimulationConfig) -> None: + simulation = Simulation.create(config) + organism = simulation.organisms[min(simulation.organisms)] + sensors = simulation.sensor_frame_for(organism.id) + assert len(sensors.values()) == 10 + assert all(-UNIT <= value <= UNIT for value in sensors.values()) + + +def test_field_of_view_hides_food_behind_observer(config: SimulationConfig) -> None: + view_config = replace( + config, + world=replace(config.world, initial_food=0, food_regen_per_tick=0), + life=replace(config.life, initial_population=1), + ) + simulation = Simulation.create(view_config) + genome = replace(simulation.organisms[1].genome, field_of_view=1) + simulation.organisms[1] = make_organism( + id=1, + genome=genome, + x=50 * UNIT, + y=50 * UNIT, + heading=0, + ) + simulation.environment.foods = { + 1: Food(id=1, x=45 * UNIT, y=50 * UNIT, energy=UNIT) + } + sensors = simulation.sensor_frame_for(1) + assert sensors.food_distance == UNIT + assert sensors.food_bearing == 0 +``` + +Add a separate toroidal fixture that places one founder at `x=UNIT` and one food +item at `x=99 * UNIT`; assert the food distance is less than `UNIT // 10` and the +bearing points along the shortest wrapped direction. The public test helper is +`sensor_frame_for(organism_id)`; keep `_sensor_frame` private. + +- [ ] **Step 2: Implement simulation construction** + +Define: + +```python +@dataclass +class Simulation: + config: SimulationConfig + rng: Rng + environment: Environment + organisms: dict[int, Organism] + ancestor_genome: Genome + tick: int = 0 + next_organism_id: int = 1 + lineage: list[LineageEvent] = field(default_factory=list) + events: list[dict[str, int | str]] = field(default_factory=list) + status: str = "running" +``` + +Implement `Simulation.create(config)` to spawn food first, then founders in +monotonic ID order. Use one owned RNG stream and the exact construction order as +part of schema version 1. + +`organisms` retains both living and dead states for V1 lineage/audit output; +only living states enter snapshots and controller updates. `max_entities` caps +the total retained organism states ever created in the run, so memory remains +bounded and capacity exhaustion has one unambiguous meaning. + +Implement `_sensor_frame(organism, snapshot)` using only own state and snapshot +queries. Compute shortest toroidal `dx/dy`, map it to a discrete target heading +with `direction_for_delta()`, then to signed heading steps with +`heading_delta()`. A candidate is visible only when +`abs(error_steps) <= genome.field_of_view`; choose the visible nearest candidate +by `(distance_squared, id)`. + +Sensor normalization is exact: + +- own energy: `normalize_ratio(energy, config.life.max_energy)`; +- own age: `normalize_ratio(age, config.life.max_age)`; +- target distance: `min(UNIT, isqrt(distance_squared) * UNIT // sensor_range)`; +- target bearing: `error_steps * UNIT // (DIRECTION_COUNT // 2)`; +- density: `min(visible_candidate_count, 8) * UNIT // 8`; +- organism signal: the nearest visible organism's already-stored prior-tick + signal; +- bias: `UNIT`. + +Missing targets use distance `UNIT`, bearing 0, density 0, and signal 0. Organism +candidates always exclude the sensing organism itself. The start-of-tick +snapshot makes signal input a prior-tick value; same-tick emissions are never +visible early. + +Implement `canonical_state()` with exactly these keys: `tick`, `rng_state`, +`next_organism_id`, `next_food_id`, `foods`, `organisms`, `lineage`, and +`status`. Foods and organisms are sorted by ID; lineage is sorted by child ID. +The next-ID counters are authoritative because they change future state. + +Implement module-level `checksum_state(state)` with JSON `sort_keys=True`, +compact separators, and `allow_nan=False`, returning a SHA-256 hex digest. +`state_checksum()` is exactly `checksum_state(self.canonical_state())`. Task 11 +must serialize this same canonical state so artifact validation can recompute the +checksum offline rather than trusting a copied hash string. + +- [ ] **Step 3: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/core/test_simulation.py -q +uv run ruff check antelab/core/simulation.py tests/core/test_simulation.py +uv run mypy antelab/core/simulation.py +``` + +Expected: initialization and sensor tests pass; Ruff and mypy exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add antelab/core/simulation.py tests/core/test_simulation.py +git commit -m "feat: add deterministic population initialization" +``` + +### Task 10: Implement the authoritative tick and invariant gates + +**Files:** +- Modify: `antelab/core/simulation.py` +- Modify: `tests/core/test_simulation.py` + +- [ ] **Step 1: Add failing tick-loop tests** + +Add tests that prove: + +```python +from dataclasses import replace + +from antelab.core.environment import Food +from antelab.core.fixed import UNIT +from antelab.core.simulation import Simulation +from tests.helpers import make_organism + + +def prepared_contest( + config: SimulationConfig, + insertion_order: tuple[int, int], +) -> Simulation: + contest_config = replace( + config, + world=replace(config.world, initial_food=0, food_regen_per_tick=0), + life=replace(config.life, initial_population=1), + mutation=replace(config.mutation, probability=0, step=0), + ) + simulation = Simulation.create(contest_config) + contenders = { + 1: make_organism(id=1, x=10 * UNIT, y=10 * UNIT, energy=12 * UNIT), + 2: make_organism(id=2, x=10 * UNIT, y=10 * UNIT, energy=12 * UNIT), + } + simulation.organisms = {item_id: contenders[item_id] for item_id in insertion_order} + simulation.next_organism_id = 3 + simulation.environment.foods = { + 1: Food(id=1, x=10 * UNIT, y=10 * UNIT, energy=2 * UNIT) + } + simulation.environment.next_food_id = 2 + return simulation + + +def prepared_capacity_case(config: SimulationConfig) -> Simulation: + capacity_config = replace( + config, + world=replace( + config.world, + initial_food=0, + food_regen_per_tick=0, + max_entities=1, + ), + life=replace( + config.life, + initial_population=1, + initial_energy=config.life.max_energy, + ), + mutation=replace(config.mutation, probability=0, step=0), + ) + return Simulation.create(capacity_config) + + +def prepared_reproduction_contest( + config: SimulationConfig, + *, + seed: int, + insertion_order: tuple[int, int], +) -> Simulation: + contest_config = replace( + config, + seed=seed, + world=replace( + config.world, + initial_food=0, + food_regen_per_tick=0, + max_entities=3, + ), + life=replace( + config.life, + initial_population=1, + initial_energy=config.life.max_energy, + ), + mutation=replace(config.mutation, probability=0, step=0), + ) + simulation = Simulation.create(contest_config) + contenders = { + 1: make_organism(id=1, energy=config.life.max_energy), + 2: make_organism(id=2, energy=config.life.max_energy), + } + simulation.organisms = {item_id: contenders[item_id] for item_id in insertion_order} + simulation.next_organism_id = 3 + return simulation + + +def test_same_seed_produces_same_checksums_for_200_ticks(config: SimulationConfig) -> None: + left = Simulation.create(config) + right = Simulation.create(config) + assert [left.step().checksum for _ in range(200)] == [right.step().checksum for _ in range(200)] + + +def test_contested_food_winner_is_independent_of_dict_insertion_order( + config: SimulationConfig, +) -> None: + left = prepared_contest(config, insertion_order=(1, 2)) + right = prepared_contest(config, insertion_order=(2, 1)) + left.step() + right.step() + assert left.state_checksum() == right.state_checksum() + + +def test_capacity_limit_stops_without_culling(config: SimulationConfig) -> None: + simulation = prepared_capacity_case(config) + result = simulation.step() + assert result.status == "capacity_exceeded" + assert len(simulation.organisms) == config.world.max_entities + + +def test_last_reproduction_slot_is_keyed_not_id_ordered(config: SimulationConfig) -> None: + seed_zero = prepared_reproduction_contest( + config, + seed=0, + insertion_order=(1, 2), + ) + seed_one = prepared_reproduction_contest( + config, + seed=1, + insertion_order=(2, 1), + ) + seed_zero.step() + seed_one.step() + assert seed_zero.lineage[0].parent_id == 2 + assert seed_one.lineage[0].parent_id == 1 + + +def test_tick_energy_ledger_closes_with_food_reproduction_and_regeneration( + config: SimulationConfig, +) -> None: + ledger_config = replace( + config, + world=replace( + config.world, + initial_food=0, + max_food=2, + food_regen_per_tick=1, + max_entities=4, + ), + life=replace( + config.life, + initial_population=1, + initial_energy=20 * UNIT, + max_energy=24 * UNIT, + ), + mutation=replace(config.mutation, probability=0, step=0), + ) + simulation = Simulation.create(ledger_config) + founder = simulation.organisms[1] + simulation.environment.foods = { + 1: Food(id=1, x=founder.x, y=founder.y, energy=2 * UNIT) + } + simulation.environment.next_food_id = 2 + + ledger = simulation.step().energy + + ledger.validate() + assert ledger.food_absorbed == 2 * UNIT + assert ledger.food_created == 2 * UNIT + assert ledger.reproduction_transferred == 6 * UNIT + assert ledger.sensing_spent == founder.genome.sensor_cost + assert ledger.reproduction_spent == founder.genome.reproduction_cost + assert ( + ledger.organism_start + ledger.food_start + ledger.food_created + == ledger.organism_end + + ledger.food_end + + ledger.basal_spent + + ledger.sensing_spent + + ledger.movement_spent + + ledger.signal_spent + + ledger.reproduction_spent + + ledger.food_overflow + ) +``` + +The controlled ledger fixture must consume the colocated food, create one child, +and regenerate one food item; do not weaken it to a tick that exercises only +basal metabolism. + +- [ ] **Step 2: Implement `TickResult` and the tick order** + +Add immutable ledger and result types: + +```python +@dataclass(frozen=True) +class EnergyLedger: + organism_start: int + food_start: int + food_created: int + basal_spent: int + sensing_spent: int + movement_spent: int + signal_spent: int + food_absorbed: int + food_overflow: int + reproduction_transferred: int + reproduction_spent: int + organism_end: int + food_end: int + + def validate(self) -> None: + values = tuple(getattr(self, item.name) for item in fields(self)) + if any(value < 0 for value in values): + raise RuntimeError("energy ledger contains a negative value") + if self.food_start + self.food_created - self.food_end != ( + self.food_absorbed + self.food_overflow + ): + raise RuntimeError("food transfer does not reconcile") + available = self.organism_start + self.food_start + self.food_created + accounted = ( + self.organism_end + + self.food_end + + self.basal_spent + + self.sensing_spent + + self.movement_spent + + self.signal_spent + + self.reproduction_spent + + self.food_overflow + ) + if available != accounted: + raise RuntimeError("energy ledger does not reconcile") + + +@dataclass(frozen=True) +class TickResult: + tick: int + births: int + deaths: int + food_consumed: int + food_spawned: int + energy: EnergyLedger + status: str + checksum: str +``` + +`organism_start` and `organism_end` sum every retained organism state, alive or +dead. Food absorption and reproduction are internal transfers: they are exposed +for audit but appear only once in the conservation equation. Food regeneration +is the only external source in V1; basal metabolism, sensing, movement, +signalling, reproduction overhead, and overflow are the only sinks. +`EnergyLedger.validate()` runs inside `Simulation.step()` before the new state +becomes observable. + +Implement `Simulation.step()` in this exact order: + +1. reject calls unless status is `running`; +2. build a `SpatialSnapshot` from alive start-of-tick organisms and foods; +3. calculate every `SensorFrame` and `EffectorFrame` in sorted organism-ID order; +4. apply movement to copies of each organism and retain each requested movement + charge from `MovementOutcome` without debiting energy yet; +5. group successful eat attempts by food ID; +6. choose each contested food winner with `stable_rank(seed, tick, food_id, + organism_id)`, after comparing squared distance; +7. transfer food energy, record absorbed and overflow energy, and remove consumed + food; +8. set signal to `(genome.signal_strength * effector.signal) // UNIT`, build + `ActionCosts` from basal, sensing, requested movement, and requested signal + costs, then apply `charge_action_energy()` and aggregate its actual debits; +9. collect eligible reproduce attempts, order them by + `(stable_rank(config.seed, self.tick, parent_id), parent_id)`, and process in + that order while aggregating reported reproduction transfer and overhead; + stop with `capacity_exceeded` before creating an entity beyond the configured + cap; +10. resolve deaths without deleting lineage records; +11. spawn at most `food_regen_per_tick` without exceeding `max_food`, recording + exactly `created_count * food_energy` as external energy; +12. construct and validate the energy ledger, replace authoritative organism + state, append events, increment tick, validate invariants, and return + checksum. + +After incrementing, if status is still `running` and `tick == config.ticks`, set +status to `completed` before validation and checksum creation. A subsequent +`step()` call is rejected for every terminal status. + +Conflict selection key must be +`(distance_squared, stable_rank(config.seed, self.tick, food_id, organism_id))`; +raw ID is not a priority key. + +Reproduction opportunity uses keyed rank for the same reason. Parent ID is only +the astronomically unlikely hash-collision fallback; it must never be the first +sort key. The fixed BLAKE2b contract makes seed 0 choose parent 2 and seed 1 +choose parent 1 in the two-parent test above. + +Append only bounded notable events: birth records contain `kind`, `tick`, +`parent_id`, and `child_id`; death records contain `kind`, `tick`, `organism_id`, +and `cause`; the first terminal transition contains `kind="terminal"`, `tick`, +and `status`. Do not append per-tick movement, sensing, food, or signal events. +Because each retained organism can be born and die at most once and there is one +terminal transition, event growth is bounded by `2 * max_entities + 1`. + +- [ ] **Step 3: Implement invariant checks** + +`Simulation.validate()` must raise `RuntimeError` for: + +- an organism outside world bounds; +- negative energy or an organism above configured maximum energy; +- an alive organism with zero energy after death resolution; +- a non-founder missing a lineage edge; +- a founder with a parent, a non-founder with anything other than one parent + edge, a missing parent/child state, a parent field mismatch, a parent ID not + lower than its child ID, or a lineage generation mismatch; +- duplicate IDs or a next ID not greater than all existing IDs; +- invalid genome or controller dimensions; +- signal outside `[0, UNIT]`, an alive organism with a death cause, or a dead + organism without one; +- food outside world bounds or non-positive food energy; +- entity count above the configured cap; +- RNG state outside unsigned 64-bit range, tick outside `[0, config.ticks]`, or + a terminal/running status inconsistent with tick and living population. + +Also validate that dictionary keys equal embedded organism/food IDs and that +embedded IDs are unique; checking dictionary-key uniqueness alone proves +nothing because Python already enforces it. + +Tick-budget completion sets status `completed`; extinction sets status +`extinct`; both return normally. Capacity exhaustion sets status +`capacity_exceeded` and returns normally without adding the extra child. + +- [ ] **Step 4: Run tick-loop tests and the complete core suite** + +Run: + +```bash +uv run pytest tests/core/test_simulation.py -q +uv run pytest tests/core -q +uv run ruff check antelab/core tests/core +uv run mypy antelab/core +``` + +Expected: all core tests pass; Ruff and mypy exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add antelab/core/simulation.py tests/core/test_simulation.py +git commit -m "feat: run deterministic evolutionary ticks" +``` + +### Task 11: Add the versioned minimal artifact + +**Files:** +- Create: `antelab/artifacts/__init__.py` +- Create: `antelab/artifacts/schema.py` +- Create: `antelab/artifacts/writer.py` +- Create: `tests/artifacts/test_writer.py` + +- [ ] **Step 1: Write artifact tests** + +Create `tests/artifacts/test_writer.py` with this byte-identity test: + +```python +from pathlib import Path + +from antelab.artifacts.schema import RunArtifact +from antelab.artifacts.writer import write_artifact +from antelab.core.simulation import Simulation +from tests.helpers import make_config + + +def test_same_run_writes_byte_identical_artifacts(tmp_path: Path) -> None: + simulation = Simulation.create(replace(make_config(), ticks=25)) + metrics: list[dict[str, int | str]] = [] + for _ in range(25): + result = simulation.step() + metrics.append( + { + "tick": result.tick, + "population": sum(item.alive for item in simulation.organisms.values()), + "food": len(simulation.environment.foods), + "births": len(simulation.lineage), + "deaths": sum(not item.alive for item in simulation.organisms.values()), + "max_generation": max(item.generation for item in simulation.organisms.values()), + "total_organism_energy": sum( + item.energy for item in simulation.organisms.values() if item.alive + ), + "genome_diversity": len( + { + item.genome.canonical_key() + for item in simulation.organisms.values() + if item.alive + } + ), + "checksum": result.checksum, + } + ) + artifact = RunArtifact.from_simulation(simulation, tuple(metrics)) + left = tmp_path / "left.json" + right = tmp_path / "right.json" + write_artifact(artifact, left) + write_artifact(artifact, right) + assert left.read_bytes() == right.read_bytes() +``` + +Add focused tests proving schema version, engine version, condition, normalized +config, seed, ancestor genome, terminal status, summary, lineage, metrics, +terminal genome distribution, notable events, canonical final state, and +checksum are present. Organisms and lineage must be sorted canonically. Invalid +lineage, mismatched top-level/final-state lineage, malformed metric keys, a +non-monotonic metric tick, or a recomputed checksum mismatch raises +`ArtifactError`. Prove the output value tree contains no float, set, bytes, UUID, +or unknown object representation. + +- [ ] **Step 2: Implement the artifact schema** + +Define immutable `RunArtifact` with: + +```python +schema_version: int +engine_version: str +run_id: str +condition: str +seed: int +ticks_requested: int +ticks_completed: int +status: str +config: dict[str, object] +ancestor_genome: dict[str, object] +summary: dict[str, int] +metrics: tuple[dict[str, int | str], ...] +lineage: tuple[dict[str, int], ...] +genome_distribution: tuple[dict[str, object], ...] +notable_events: tuple[dict[str, int | str], ...] +final_state: dict[str, object] +final_checksum: str +``` + +`RunArtifact.from_simulation(simulation, metrics)` stores +`simulation.canonical_state()` and verifies that `checksum_state(final_state)` +equals `simulation.state_checksum()`. Compute a 12-hex configuration digest from +canonical normalized configuration and set run ID to +`antelab-v1-{config_digest}-seed-{seed}-ticks-{ticks_completed}`. No timestamp is +allowed in the canonical artifact because it would break byte reproducibility. + +The summary has exactly `population`, `food`, `births`, `deaths`, +`max_generation`, `total_organism_energy`, and `genome_diversity`. Population, +energy, and diversity count living organisms; births are lineage edges; deaths +are retained dead states. The terminal genome distribution groups living +organisms by full canonical genome, stores `{genome, count}` records sorted by +the genome's canonical JSON key, and is therefore a real distribution rather +than a lossy mean. Notable events are the bounded birth, death, extinction, and +capacity events already held by the simulation; food spawn/consume activity +stays in aggregate metrics. + +Implement `validate()` and recursive `assert_canonical_tree()` that allows only +`None`, `bool`, `int`, `str`, lists/tuples of allowed values, and string-keyed +dictionaries of allowed values. It rejects float, set, bytes, UUID, and unknown +objects. Treat bool separately before int so the validator does not depend on +Python's bool/int subclass relationship. + +`validate()` also enforces exact top-level summary and metric key sets; strictly +increasing metric ticks; last metric tick and checksum equal to the terminal +state; run ID/config digest consistency; top-level lineage byte-equivalent to +`final_state["lineage"]`; one valid parent edge per non-founder; and +`checksum_state(final_state) == final_checksum`. This is an offline integrity +check: the source `Simulation` object is not available to the artifact reader. + +- [ ] **Step 3: Implement canonical writing** + +`writer.py` exposes: + +```python +def write_artifact(artifact: RunArtifact, path: Path) -> None: + artifact.validate() + payload = json.dumps( + artifact.to_dict(), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(payload + "\n", encoding="utf-8") +``` + +- [ ] **Step 4: Run focused tests and checks** + +Run: + +```bash +uv run pytest tests/artifacts/test_writer.py -q +uv run ruff check antelab/artifacts tests/artifacts +uv run mypy antelab/artifacts +``` + +Expected: artifact tests pass; Ruff and mypy exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add antelab/artifacts/__init__.py antelab/artifacts/schema.py antelab/artifacts/writer.py tests/artifacts/test_writer.py +git commit -m "feat: write canonical evolution artifacts" +``` + +### Task 12: Add the headless runner, experiment fixture, and CLI + +**Files:** +- Create: `antelab/experiments/__init__.py` +- Create: `antelab/experiments/runner.py` +- Create: `antelab/cli.py` +- Create: `experiments/foraging-genesis.yaml` +- Create: `tests/experiments/test_runner.py` +- Create: `tests/test_cli.py` + +- [ ] **Step 1: Write runner and CLI tests** + +Tests must prove: + +```python +import subprocess +import sys +from pathlib import Path + +from antelab.experiments.runner import run_experiment + +CONFIG_PATH = Path("experiments/foraging-genesis.yaml") + + +def test_runner_stops_at_tick_budget_and_writes_artifact(tmp_path: Path) -> None: + output = tmp_path / "run.json" + result = run_experiment(CONFIG_PATH, output=output, tick_override=25) + assert result.ticks_completed == 25 + assert output.exists() + + +def test_cli_reports_extinction_as_successful_experiment(tmp_path: Path) -> None: + extinction_config = tmp_path / "extinction.yaml" + extinction_config.write_text( + """schema_version: 1 +condition: uncontrolled_baseline +seed: 3 +ticks: 4 +world: + width: 10240 + height: 10240 + initial_food: 0 + max_food: 0 + food_energy: 1024 + food_regen_per_tick: 0 + max_entities: 4 +life: + initial_population: 1 + initial_energy: 1024 + max_energy: 2048 + max_age: 1 + reproduction_cooldown: 10 +mutation: {probability: 0, step: 0} +""", + encoding="utf-8", + ) + output = tmp_path / "extinct.json" + completed = subprocess.run( + [ + sys.executable, + "-m", + "antelab.cli", + "run", + str(extinction_config), + "--output", + str(output), + ], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0 + assert "status=extinct" in completed.stdout +``` + +Also test exit code 2 for invalid config and exit code 3 for broken engine +invariants. + +- [ ] **Step 2: Create the first experiment configuration** + +Create `experiments/foraging-genesis.yaml`: + +```yaml +schema_version: 1 +condition: uncontrolled_baseline +seed: 42 +ticks: 5000 +world: + width: 204800 + height: 204800 + initial_food: 320 + max_food: 512 + food_energy: 4096 + food_regen_per_tick: 1 + max_entities: 2048 +life: + initial_population: 64 + initial_energy: 12288 + max_energy: 32768 + max_age: 2500 + reproduction_cooldown: 64 +mutation: + probability: 64 + step: 32 +``` + +- [ ] **Step 3: Implement the runner** + +`run_experiment(config_path, output, tick_override=None)` must: + +1. load and validate config; +2. apply a positive tick override by returning a replaced immutable config; +3. create a simulation; +4. collect one metrics record every 10 ticks and at terminal state, replacing + rather than duplicating a record when the terminal tick is divisible by 10; +5. stop at requested ticks or terminal status; +6. construct, validate, and write `RunArtifact`; +7. return the artifact. + +Metrics records contain exactly `tick`, `population`, `food`, cumulative +`births`, cumulative `deaths`, `max_generation`, `total_organism_energy`, +`genome_diversity`, and `checksum`. Population, energy, and diversity cover +living organisms. Births equal the cumulative lineage-edge count; deaths equal +the cumulative retained dead-state count. The checksum is the state checksum at +that metric tick, not an artifact-file hash. + +- [ ] **Step 4: Implement CLI exit codes** + +`antelab/cli.py` uses argparse with one command: + +```text +antelab run CONFIG --output PATH [--ticks N] +``` + +`main(argv=None) -> int` returns: + +- 0 for completed, extinct, or capacity-exceeded experimental outcomes with a + valid artifact; +- 2 for CLI or configuration errors; +- 3 for engine invariant or artifact validation failures. + +Print one stable summary line: + +```text +run_id= status= ticks= population= checksum= +``` + +Add `if __name__ == "__main__": raise SystemExit(main())` for module execution. + +- [ ] **Step 5: Run runner, CLI, and full tests** + +Run: + +```bash +uv run pytest tests/experiments/test_runner.py tests/test_cli.py -q +uv run pytest -q +uv run ruff check antelab tests +uv run mypy antelab +``` + +Expected: all tests pass; Ruff and mypy exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add antelab/experiments/__init__.py antelab/experiments/runner.py antelab/cli.py experiments/foraging-genesis.yaml tests/experiments/test_runner.py tests/test_cli.py +git commit -m "feat: run headless evolution experiments" +``` + +### Task 13: Add repeatability and local performance verification + +**Files:** +- Create: `scripts/benchmark_kernel.py` +- Modify: `README.md` +- Modify: `RUNNING.md` +- Modify: `ARCHITECTURE.md` +- Test: complete repository + +- [ ] **Step 1: Implement the benchmark script** + +Create `scripts/benchmark_kernel.py`: + +```python +from __future__ import annotations + +import argparse +import tempfile +import time +from pathlib import Path + +from antelab.experiments.runner import run_experiment + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--ticks", type=int, default=5000) + parser.add_argument("--max-seconds", type=float, default=30.0) + parser.add_argument("--enforce", action="store_true") + args = parser.parse_args(argv) + if args.ticks <= 0 or args.max_seconds <= 0: + parser.error("ticks and max-seconds must be positive") + + started = time.perf_counter() + with tempfile.TemporaryDirectory(prefix="antelab-benchmark-") as directory: + artifact = run_experiment( + args.config, + output=Path(directory) / "run.json", + tick_override=args.ticks, + ) + elapsed = time.perf_counter() - started + rate = artifact.ticks_completed / elapsed + population = artifact.summary["population"] + print( + f"elapsed={elapsed:.6f} ticks_per_second={rate:.2f} " + f"population={population} checksum={artifact.final_checksum}" + ) + return int(args.enforce and elapsed > args.max_seconds) + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +The script writes only one temporary final artifact and removes it on exit; it +does not emit per-tick files. + +The default command is: + +```text +python scripts/benchmark_kernel.py --config experiments/foraging-genesis.yaml --ticks 5000 --max-seconds 30 +``` + +- [ ] **Step 2: Run byte-level repeatability verification** + +Run: + +```bash +uv run antelab run experiments/foraging-genesis.yaml --ticks 500 --output /tmp/antelab-run-a.json +uv run antelab run experiments/foraging-genesis.yaml --ticks 500 --output /tmp/antelab-run-b.json +cmp /tmp/antelab-run-a.json /tmp/antelab-run-b.json +shasum -a 256 /tmp/antelab-run-a.json /tmp/antelab-run-b.json +``` + +Expected: `cmp` exits 0 and both SHA-256 values are identical. + +- [ ] **Step 3: Run the local smoke performance budget** + +Run: + +```bash +uv run python scripts/benchmark_kernel.py --config experiments/foraging-genesis.yaml --ticks 5000 --max-seconds 30 --enforce +``` + +Expected on the current arm64/Python 3.12.11 reference host: exit 0, elapsed time +at most 30 seconds, and a 64-character checksum. If this fails, profile before +changing the budget. Do not weaken correctness or determinism to pass timing. + +- [ ] **Step 4: Update human documentation with verified commands** + +Update README and RUNNING with the exact passing setup, run, repeatability, and +benchmark commands. Update ARCHITECTURE with the final module map and the actual +numeric contract. Do not claim adaptation, intelligence, or open-ended evolution; +this plan proves only a functioning deterministic evolutionary substrate. + +- [ ] **Step 5: Run the final verification gate** + +Run: + +```bash +uv lock --check +uv run ruff check antelab tests scripts +uv run mypy antelab scripts +uv run pytest -q +uv build +git diff --check +git status --short --branch +``` + +Expected: + +- lockfile check exits 0; +- Ruff and mypy exit 0; +- all tests pass with zero failures; +- source and wheel builds succeed; +- diff check is empty; +- status contains only the intended documentation and benchmark changes. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/benchmark_kernel.py README.md RUNNING.md ARCHITECTURE.md +git commit -m "docs: verify deterministic evolution kernel" +``` + +## Approved-Design Coverage Audit + +| Approved contract | Implemented by | Evidence gate | +| --- | --- | --- | +| Stable toroidal physics and integer numerics | Tasks 2, 6, 7, 10 | Geometry, wrap, invariant, and cross-run checksum tests | +| No explicit fitness score | Tasks 5, 8, 10 | Reproduction depends only on controller attempt, energy, cooldown, and capacity | +| Declared energy sources, transfers, and sinks | Tasks 4, 7, 8, 10 | Per-tick closed `EnergyLedger` including sensing and reproduction overhead | +| Local information and bounded effectors | Tasks 5, 6, 9 | Ten-value local sensor and five-value effector tests | +| Owned deterministic randomness | Tasks 3, 9, 10 | SplitMix64 vectors, canonical RNG state, keyed conflict ordering | +| Inherited mutation and asexual lineage | Tasks 4, 8, 10 | Zero/full mutation, parentage, generation, and lineage invariants | +| Explicit extinction and safety limits | Tasks 3, 10, 12 | Terminal-status and CLI exit-code tests | +| Headless engine/observer separation | Tasks 1, 12 | Dependency audit and no API/frontend/network core | +| Reproducible, offline-verifiable artifact | Tasks 9, 11, 12, 13 | Canonical final state, recomputed checksums, byte comparison | +| One-command local experiment | Tasks 12, 13 | Installed CLI smoke run and documented reproduction command | + +The following approved-design items remain intentionally outside this first +kernel plan: fertility zones, statistical control runs, adaptation claims, +ancestor replay, checkpoints/resume, compressed replay, comparison/verification +commands, HTTP/WebSocket APIs, observer UI, lifetime learning, sexual +reproduction, predation, seasons, co-evolving environments, and LLM observer +plugins. They require later plans and cannot be smuggled into this slice. + +## Final Acceptance Gate + +The first implementation slice is complete only when all statements below are +backed by fresh command output: + +- the historical company, receipt, LLM, generated demo, old frontend, and old + specs are absent from the committed tree; +- `git ls-tree -r --name-only HEAD` contains only the locked new tree plus GitHub + metadata generated by the implementation; +- no default or optional core dependency performs network calls; +- same config, seed, and ticks produce byte-identical artifacts; +- mutation disabled creates genetically identical children; +- mutation enabled is deterministic and bounded; +- every non-founder has exactly one valid lineage edge; +- energy cannot be negative and parent-plus-child energy equals pre-reproduction + energy minus the declared reproduction overhead; +- extinction and capacity exhaustion are valid explicit outcomes; +- the full test, lint, typecheck, build, repeatability, and smoke performance + gates pass. + +The next project after this gate is the separately planned evolution proof +harness. It must not be folded into this implementation opportunistically. From 23060035f5e2547443f28951a251c92fc6688aab Mon Sep 17 00:00:00 2001 From: Merengues0x2A Date: Fri, 10 Jul 2026 10:53:34 +0800 Subject: [PATCH 03/35] docs: make cleanup worktree-safe --- ...26-07-10-deterministic-evolution-kernel.md | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md index 5bbc392..2265258 100644 --- a/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md +++ b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md @@ -15,11 +15,12 @@ The approved design is `docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md`. -The starting branch is `main`. Commit `f950c14` adds the approved design. The -working tree already marks the historical tracked tree as deleted; none of those -deletions are staged. Task 1 replaces the minimum repository skeleton, validates -it, then explicitly stages the approved retirement boundary together with the -replacement skeleton. +Commit `f950c14` adds the approved design. The primary checkout already marks the +historical tracked tree as deleted, while the required isolated implementation +worktree starts from clean tracked files. Task 1 works in either state: it first +materializes the exact approved retirement boundary with `git rm`, then replaces +the minimum repository skeleton, validates it, and commits cleanup plus skeleton +as one auditable change. This plan implements only subproject 1 from the design: @@ -130,7 +131,7 @@ Responsibilities are non-overlapping: - CI runs correctness checks, not the wall-clock budget; the benchmark script reports the local timing and fails only when explicitly passed `--enforce`. -### Task 1: Reboot the repository skeleton and stage the approved cleanup +### Task 1: Reboot the repository skeleton and commit the approved cleanup **Files:** - Create: `.github/workflows/ci.yml` @@ -156,17 +157,32 @@ Run: ```bash git status --short --branch git diff --cached --name-status -git rev-parse HEAD +TASK1_BASE=$(git rev-parse HEAD) +git merge-base --is-ancestor f950c1463d97ac842808e7b6b9d70e08d739b3e4 "$TASK1_BASE" +git ls-tree -r --name-only "$TASK1_BASE" -- docs/superpowers ``` Expected: -- `HEAD` is `f950c1463d97ac842808e7b6b9d70e08d739b3e4`; -- historical files appear as unstaged `D` entries; +- `TASK1_BASE` records the execution branch tip before cleanup; +- approved design commit `f950c1463d97ac842808e7b6b9d70e08d739b3e4` + is an ancestor of that tip; - the cached diff is empty; -- the approved design and this plan exist. +- the approved design and this plan are tracked at `TASK1_BASE`. -- [ ] **Step 2: Write the failing package identity test** +- [ ] **Step 2: Materialize only the approved retirement boundary** + +Run this exact path-scoped removal before creating replacement files: + +```bash +git rm -r --ignore-unmatch -- .env.example .github/workflows/frontend-ci.yml CLAUDE.md CONSTITUTION.md DESIGN.md DISCOVERIES.md Dockerfile.backend EXPERIMENTS.md ROADMAP.md SPEC_STATUS.md antelab/api antelab/config antelab/engine antelab/experiments antelab/llm antelab/season_bundle.py cast docker-compose.yaml docs/README.md docs/plans docs/research experiments frontend prompts scripts seasons specs tests +``` + +Expected: only paths inside this explicit boundary become staged deletions. Do +not use shell `rm`, wildcard expansion, `git add .`, or any repository-wide +cleanup command. Do not touch `docs/superpowers/`. + +- [ ] **Step 3: Write the failing package identity test** Create `tests/__init__.py` as an empty file and create `tests/test_package.py`: @@ -179,7 +195,7 @@ def test_package_identity() -> None: assert __version__ == "0.2.0" ``` -- [ ] **Step 3: Run the test to verify the skeleton is absent** +- [ ] **Step 4: Run the test to verify the skeleton is absent** Run: @@ -189,10 +205,10 @@ python3 -m pytest tests/test_package.py -q Expected: FAIL because the deleted historical package is unavailable or does not expose version `0.2.0`. If the global interpreter lacks pytest, record that -environment failure and continue to Step 4; Step 7 is the authoritative red/green +environment failure and continue to Step 5; Step 8 is the authoritative red/green verification in the new environment. -- [ ] **Step 4: Create the minimal package and dependency contract** +- [ ] **Step 5: Create the minimal package and dependency contract** Create `antelab/__init__.py`: @@ -246,7 +262,7 @@ warn_return_any = true warn_unused_configs = true ``` -- [ ] **Step 5: Recreate the root project contract** +- [ ] **Step 6: Recreate the root project contract** Create `.gitignore`: @@ -403,7 +419,7 @@ jobs: - run: uv run pytest -q ``` -- [ ] **Step 6: Lock dependencies** +- [ ] **Step 7: Lock dependencies** Run: @@ -416,7 +432,7 @@ Expected: `uv.lock` is created and the editable `antelab==0.2.0` package is installed without FastAPI, Uvicorn, HTTPX, OpenAI, Anthropic, or frontend dependencies. -- [ ] **Step 7: Run the package test in the new environment** +- [ ] **Step 8: Run the package test in the new environment** Run: @@ -426,7 +442,7 @@ uv run pytest tests/test_package.py -q Expected: `1 passed`. -- [ ] **Step 8: Stage only the replacement skeleton and approved retirements** +- [ ] **Step 9: Stage only the replacement skeleton and approved retirements** Stage recreated and new files explicitly: @@ -434,7 +450,8 @@ Stage recreated and new files explicitly: git add .github/workflows/ci.yml .gitignore AGENTS.md ARCHITECTURE.md CONTRIBUTING.md LICENSE Makefile README.md RUNNING.md pyproject.toml uv.lock antelab/__init__.py tests/__init__.py tests/test_package.py ``` -Stage updates/deletions only under the approved historical paths: +Reassert updates/deletions only under the approved historical paths; this is +idempotent after Step 2 and also stages any replacement overlap: ```bash git add -u -- .env.example .github/workflows/frontend-ci.yml CLAUDE.md CONSTITUTION.md DESIGN.md DISCOVERIES.md Dockerfile.backend EXPERIMENTS.md ROADMAP.md SPEC_STATUS.md antelab/api antelab/config antelab/engine antelab/experiments antelab/llm antelab/season_bundle.py cast docker-compose.yaml docs/README.md docs/plans docs/research experiments frontend prompts scripts seasons specs tests @@ -443,7 +460,7 @@ git add -u -- .env.example .github/workflows/frontend-ci.yml CLAUDE.md CONSTITUT Do not stage `docs/superpowers/`; the approved design and implementation plan are already committed separately. -- [ ] **Step 9: Audit the cleanup before committing** +- [ ] **Step 10: Audit the cleanup before committing** Run: @@ -464,7 +481,7 @@ Expected: - the unstaged deletion list is empty after every historical path has been either recreated or intentionally staged. -- [ ] **Step 10: Commit the formal repository reboot** +- [ ] **Step 11: Commit the formal repository reboot** ```bash git commit -m "chore: reboot repository for digital evolution" From 9fb41d3f411eb9c981a58b8b732602a366da92ea Mon Sep 17 00:00:00 2001 From: Merengues0x2A Date: Fri, 10 Jul 2026 11:17:26 +0800 Subject: [PATCH 04/35] docs: pin isolated implementation runtime --- ...2026-07-10-deterministic-evolution-kernel.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md index 2265258..3e2244b 100644 --- a/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md +++ b/docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md @@ -51,6 +51,7 @@ Files present after this plan: ```text .github/workflows/ci.yml .gitignore +.python-version AGENTS.md ARCHITECTURE.md CONTRIBUTING.md @@ -136,6 +137,7 @@ Responsibilities are non-overlapping: **Files:** - Create: `.github/workflows/ci.yml` - Create: `.gitignore` +- Create: `.python-version` - Create: `AGENTS.md` - Create: `ARCHITECTURE.md` - Create: `CONTRIBUTING.md` @@ -218,6 +220,12 @@ Create `antelab/__init__.py`: __version__ = "0.2.0" ``` +Create `.python-version` containing exactly: + +```text +3.12 +``` + Create `pyproject.toml`: ```toml @@ -284,6 +292,7 @@ benchmarks/*.json .codex/ .claude/ .gstack/ +.superpowers/ ``` Recreate `LICENSE` with the unchanged MIT text and copyright line @@ -425,12 +434,14 @@ Run: ```bash uv lock -uv sync --extra dev +uv sync --python 3.12 --extra dev +.venv/bin/python --version ``` Expected: `uv.lock` is created and the editable `antelab==0.2.0` package is installed without FastAPI, Uvicorn, HTTPX, OpenAI, Anthropic, or frontend -dependencies. +dependencies. The version command reports Python 3.12.x; a 3.13 environment is +not valid evidence for the declared local performance baseline. - [ ] **Step 8: Run the package test in the new environment** @@ -447,7 +458,7 @@ Expected: `1 passed`. Stage recreated and new files explicitly: ```bash -git add .github/workflows/ci.yml .gitignore AGENTS.md ARCHITECTURE.md CONTRIBUTING.md LICENSE Makefile README.md RUNNING.md pyproject.toml uv.lock antelab/__init__.py tests/__init__.py tests/test_package.py +git add .github/workflows/ci.yml .gitignore .python-version AGENTS.md ARCHITECTURE.md CONTRIBUTING.md LICENSE Makefile README.md RUNNING.md pyproject.toml uv.lock antelab/__init__.py tests/__init__.py tests/test_package.py ``` Reassert updates/deletions only under the approved historical paths; this is From 04d8d334e598e830ec35ac60f56e827770876d34 Mon Sep 17 00:00:00 2001 From: Merengues0x2A Date: Fri, 10 Jul 2026 11:26:03 +0800 Subject: [PATCH 05/35] chore: reboot repository for digital evolution --- .env.example | 26 - .github/workflows/ci.yml | 49 +- .github/workflows/frontend-ci.yml | 57 - .gitignore | 235 +- .python-version | 1 + AGENTS.md | 35 +- ARCHITECTURE.md | 336 +- CLAUDE.md | 106 - CONSTITUTION.md | 194 - CONTRIBUTING.md | 137 +- DESIGN.md | 165 - DISCOVERIES.md | 121 - Dockerfile.backend | 13 - EXPERIMENTS.md | 204 - Makefile | 93 +- README.md | 268 +- ROADMAP.md | 130 - RUNNING.md | 200 +- SPEC_STATUS.md | 305 - antelab/__init__.py | 4 +- antelab/api/__init__.py | 0 antelab/api/event_store.py | 361 - antelab/api/server.py | 917 - antelab/config/__init__.py | 0 antelab/config/company.yaml | 152 - antelab/config/default.yaml | 191 - antelab/config/identity.py | 150 - antelab/config/loader.py | 870 - antelab/engine/__init__.py | 0 antelab/engine/agent.py | 327 - antelab/engine/market.py | 206 - antelab/engine/measurement.py | 242 - antelab/engine/org.py | 240 - antelab/engine/pattern.py | 162 - antelab/engine/receipts.py | 554 - antelab/engine/space.py | 140 - antelab/engine/tick.py | 150 - antelab/engine/types.py | 246 - antelab/engine/valuation.py | 163 - antelab/engine/world.py | 2818 - antelab/experiments/__init__.py | 1 - antelab/experiments/compare.py | 86 - antelab/experiments/orchestrator.py | 630 - antelab/experiments/stats.py | 295 - antelab/llm/__init__.py | 0 antelab/llm/client.py | 259 - antelab/llm/narrative.py | 296 - antelab/season_bundle.py | 223 - cast/company/README.md | 6 - cast/company/avery.yaml | 17 - cast/company/ilya.yaml | 17 - cast/company/mina.yaml | 16 - docker-compose.yaml | 27 - docs/README.md | 23 - .../2026-04-29-company-emergence-design.md | 232 - docs/research/2026-05-15-agent-trend-radar.md | 240 - experiments/company-founder-duo.yaml | 74 - experiments/company-genesis.yaml | 70 - experiments/company-hostile-market.yaml | 41 - experiments/company-late-shock.yaml | 64 - experiments/company-market-boom.yaml | 64 - experiments/company-rapid-growth.yaml | 53 - experiments/company-remote-team.yaml | 71 - experiments/company-skeleton-crew.yaml | 57 - experiments/company-survival-gauntlet.yaml | 89 - experiments/company-talent-war.yaml | 49 - experiments/llm-benchmark.yaml | 94 - frontend/Dockerfile | 12 - frontend/PIXEL_QA_REPORT.md | 70 - frontend/agent_debug.png | Bin 2356148 -> 0 bytes frontend/e2e/fixtures/receipt-artifact.json | 94 - frontend/e2e/receipt-first.spec.ts | 108 - frontend/e2e/show-deck-demo.spec.ts | 70 - frontend/eslint.config.js | 19 - frontend/index.html | 18 - frontend/package-lock.json | 3896 - frontend/package.json | 37 - frontend/playwright.config.ts | 28 - .../public/cast/company/portraits/.gitkeep | 0 frontend/public/demo/founder-duo-seed-55.json | 56812 ------------- frontend/public/demo/gauntlet-seed-42.json | 69100 ---------------- frontend/public/demo/gauntlet-seed-99.json | 35222 -------- frontend/public/demo/genesis-seed-42.json | 31281 ------- frontend/public/demo/genesis-seed-77.json | 34834 -------- .../public/demo/hostile-market-seed-13.json | 20971 ----- frontend/public/demo/late-shock-seed-88.json | 32507 -------- frontend/public/demo/manifest.json | 58 - frontend/public/demo/market-boom-seed-91.json | 30662 ------- frontend/public/demo/receipt-unsupported.json | 113 - frontend/public/demo/remote-team-seed-33.json | 31662 ------- frontend/public/demo/talent-war-seed-77.json | 26982 ------ ...obal-map-idle-2026-04-13T02-35-37-869Z.png | Bin 21914 -> 0 bytes ...obal-map-idle-2026-04-13T02-37-49-343Z.png | Bin 813076 -> 0 bytes ...ap-idle-pass2-2026-04-13T02-39-22-689Z.png | Bin 506306 -> 0 bytes ...istrict-focus-2026-04-13T02-37-58-736Z.png | Bin 733956 -> 0 bytes ...t-focus-pass2-2026-04-13T02-39-29-265Z.png | Bin 495022 -> 0 bytes ...otlight-pass2-2026-04-13T02-39-45-982Z.png | Bin 512399 -> 0 bytes ...ability-pass2-2026-04-13T02-39-50-267Z.png | Bin 512391 -> 0 bytes frontend/scripts/e2e-dev-stack.sh | 44 - frontend/src/App.tsx | 472 - frontend/src/ErrorBoundary.tsx | 60 - frontend/src/agentVisualMovement.test.ts | 95 - frontend/src/agentVisualMovement.ts | 191 - frontend/src/appModel.ts | 384 - frontend/src/artifactWorkbench.test.ts | 275 - frontend/src/artifactWorkbench.ts | 475 - frontend/src/bookmarkImport.test.ts | 107 - frontend/src/bookmarkImport.ts | 267 - frontend/src/cast/companyCast.ts | 61 - frontend/src/components/AgentHaloLayer.tsx | 109 - frontend/src/components/CastStrip.tsx | 62 - frontend/src/components/DemoPlayer.tsx | 510 - .../src/components/FloatingDetailCard.tsx | 142 - frontend/src/components/IconDock.tsx | 625 - frontend/src/components/MapAtmosphere.tsx | 70 - frontend/src/components/NarrativeBar.tsx | 310 - frontend/src/components/PixiWorldStage.tsx | 957 - frontend/src/components/TopBar.tsx | 74 - frontend/src/components/WorldViewport.tsx | 307 - .../tools/ArtifactWorkbenchPanel.tsx | 304 - .../components/tools/EventTimelinePanel.tsx | 601 - .../src/components/tools/HistoryOpsPanel.tsx | 44 - .../src/components/tools/ReplayStripPanel.tsx | 94 - frontend/src/displayScalars.test.ts | 20 - frontend/src/displayScalars.ts | 25 - frontend/src/highlightMoments.test.ts | 68 - frontend/src/highlightMoments.ts | 130 - frontend/src/main.tsx | 12 - frontend/src/observerShare.test.ts | 21 - frontend/src/observerShare.ts | 61 - frontend/src/pixelSpec.test.ts | 23 - frontend/src/pixelSpec.ts | 42 - frontend/src/primaryMapLocation.test.ts | 45 - frontend/src/receiptSignals.test.ts | 69 - frontend/src/receiptSignals.ts | 91 - frontend/src/shareUtils.ts | 105 - frontend/src/showDeckState.test.ts | 42 - frontend/src/showDeckState.ts | 20 - frontend/src/showFeedFormat.test.ts | 32 - frontend/src/showFeedFormat.ts | 78 - frontend/src/showIdentity.test.ts | 262 - frontend/src/showIdentity.ts | 253 - frontend/src/styles/cast-strip.css | 149 - frontend/src/styles/components.css | 94 - frontend/src/styles/effects.css | 85 - frontend/src/styles/icon-dock.css | 966 - frontend/src/styles/layout.css | 58 - frontend/src/styles/narrative-bar.css | 214 - frontend/src/styles/reset.css | 65 - frontend/src/styles/theme-toggle.css | 66 - frontend/src/styles/themes/brutalist.css | 102 - frontend/src/styles/themes/control-room.css | 66 - frontend/src/styles/themes/index.css | 6 - frontend/src/styles/themes/theater.css | 102 - frontend/src/styles/tokens.css | 76 - frontend/src/styles/top-bar.css | 234 - frontend/src/styles/typography.css | 40 - frontend/src/styles/world-viewport.css | 1504 - frontend/src/useDemoReplay.test.ts | 144 - frontend/src/useDemoReplay.ts | 279 - frontend/src/useSimulationData.ts | 301 - frontend/src/useTimelineBookmarks.ts | 309 - frontend/src/useTimelineFilters.ts | 171 - frontend/src/vite-env.d.ts | 1 - frontend/tsconfig.json | 19 - frontend/tsconfig.node.json | 17 - frontend/ui-scan-before-gamepass.png | Bin 210457 -> 0 bytes frontend/ui-scan-evolution.gif | Bin 2765968 -> 0 bytes frontend/ui-scan-evolution.mp4 | Bin 442053 -> 0 bytes frontend/ui-scan-pass1.png | Bin 254334 -> 0 bytes frontend/ui-scan-pass10.png | Bin 700138 -> 0 bytes frontend/ui-scan-pass11.png | Bin 448154 -> 0 bytes frontend/ui-scan-pass12.png | Bin 448154 -> 0 bytes frontend/ui-scan-pass13.png | Bin 451714 -> 0 bytes frontend/ui-scan-pass14.png | Bin 774125 -> 0 bytes frontend/ui-scan-pass15-assets.png | Bin 34976 -> 0 bytes frontend/ui-scan-pass17-pixi-density-fix.png | Bin 555681 -> 0 bytes .../ui-scan-pass18-pixi-organic-polygons.png | Bin 35734 -> 0 bytes frontend/ui-scan-pass19-road-tile-band.png | Bin 35734 -> 0 bytes frontend/ui-scan-pass2.png | Bin 357557 -> 0 bytes .../ui-scan-pass20-road-intersections.png | Bin 364896 -> 0 bytes .../ui-scan-pass21-road-junction-types.png | Bin 364896 -> 0 bytes frontend/ui-scan-pass23-road-endcaps.png | Bin 368247 -> 0 bytes frontend/ui-scan-pass3.png | Bin 425731 -> 0 bytes frontend/ui-scan-pass4.png | Bin 438613 -> 0 bytes frontend/ui-scan-pass5.png | Bin 448796 -> 0 bytes frontend/ui-scan-pass6.png | Bin 299759 -> 0 bytes frontend/ui-scan-pass7.png | Bin 563340 -> 0 bytes frontend/ui-scan-pass8.png | Bin 603828 -> 0 bytes frontend/ui-scan-pass9.png | Bin 687196 -> 0 bytes frontend/ui-scan.png | Bin 206176 -> 0 bytes frontend/vite.config.ts | 29 - prompts/README.md | 21 - pyproject.toml | 34 +- scripts/aggregate_stats.py | 42 - scripts/benchmark_llm.py | 544 - scripts/compare_artifacts.py | 85 - scripts/generate_demo_data.py | 212 - scripts/restart.sh | 78 - scripts/run_matrix.py | 201 - seasons/company.yaml | 34 - specs/001-spatial-graph.md | 43 - specs/002-craft-primitive.md | 54 - specs/003-reproducible-runs.md | 35 - specs/004-persistence-save-load.md | 30 - specs/005-longrun-stability.md | 23 - specs/006-experiment-orchestrator.md | 26 - specs/007-statistical-analysis.md | 25 - specs/008-queryable-history-replay.md | 64 - specs/009-history-ops-config.md | 58 - specs/010-history-operations-panel.md | 58 - specs/011-replay-ux-enhancements.md | 59 - specs/012-replay-keyboard-shortcuts.md | 53 - specs/013-living-world.md | 210 - specs/014-timeline-event-filters.md | 57 - specs/015-timeline-advanced-query-presets.md | 63 - specs/016-timeline-event-detail-drawer.md | 59 - specs/017-event-detail-structured-parsing.md | 56 - specs/018-event-detail-context-links.md | 54 - specs/019-event-investigation-shortcuts.md | 55 - ...-event-bookmarks-and-investigation-path.md | 58 - specs/021-bookmark-tags-and-groups.md | 58 - specs/022-bookmark-import-export.md | 57 - ...rsioning-and-import-conflict-resolution.md | 114 - specs/028-persistent-event-store.md | 86 - specs/029-artifact-workbench.md | 85 - specs/030-statistical-inference-v2.md | 82 - specs/031-large-run-operations.md | 75 - specs/032-direct-broadcast-read.md | 63 - specs/033-founder-company-seed.md | 145 - specs/034-first-hires-and-role-claims.md | 132 - ...ompany-artifacts-and-knowledge-transfer.md | 136 - ...partment-emergence-and-operating-rhythm.md | 129 - specs/037-company-survival-gauntlet.md | 141 - specs/038-antelab-show-deck-visual-system.md | 59 - specs/039-agent-receipt-layer.md | 282 - specs/README.md | 28 - specs/TEMPLATE.md | 104 - specs/archive/001-game-observer-ui.md | 70 - .../001-observer-agent-free-walking.md | 66 - specs/archive/001-real-llm.md | 114 - specs/archive/002-tick-perception-snapshot.md | 123 - specs/archive/003-unified-config.md | 186 - specs/archive/004-observer-console.md | 91 - .../archive/005-narrative-simulator-layer.md | 61 - .../006-sandbox-experiment-controls.md | 65 - ...7-live-observer-stream-and-replay-strip.md | 74 - specs/archive/012-agenttv-observer-ui-fit.md | 58 - specs/archive/012-ui-simplification.md | 178 - specs/archive/013-replay-comparison-panel.md | 56 - specs/archive/observer-ui-hardcore.md | 26 - tests/test_agent.py | 181 - tests/test_api.py | 716 - tests/test_config.py | 474 - tests/test_experiments.py | 921 - tests/test_llm.py | 173 - tests/test_market.py | 205 - tests/test_measurement.py | 96 - tests/test_narrative.py | 34 - tests/test_org.py | 158 - tests/test_package.py | 5 + tests/test_pattern.py | 150 - tests/test_receipts.py | 221 - tests/test_season_bundle.py | 89 - tests/test_space.py | 137 - tests/test_tick.py | 239 - tests/test_valuation.py | 141 - tests/test_world.py | 970 - uv.lock | 497 +- 269 files changed, 108 insertions(+), 411988 deletions(-) delete mode 100644 .env.example delete mode 100644 .github/workflows/frontend-ci.yml create mode 100644 .python-version delete mode 100644 CLAUDE.md delete mode 100644 CONSTITUTION.md delete mode 100644 DESIGN.md delete mode 100644 DISCOVERIES.md delete mode 100644 Dockerfile.backend delete mode 100644 EXPERIMENTS.md delete mode 100644 ROADMAP.md delete mode 100644 SPEC_STATUS.md delete mode 100644 antelab/api/__init__.py delete mode 100644 antelab/api/event_store.py delete mode 100644 antelab/api/server.py delete mode 100644 antelab/config/__init__.py delete mode 100644 antelab/config/company.yaml delete mode 100644 antelab/config/default.yaml delete mode 100644 antelab/config/identity.py delete mode 100644 antelab/config/loader.py delete mode 100644 antelab/engine/__init__.py delete mode 100644 antelab/engine/agent.py delete mode 100644 antelab/engine/market.py delete mode 100644 antelab/engine/measurement.py delete mode 100644 antelab/engine/org.py delete mode 100644 antelab/engine/pattern.py delete mode 100644 antelab/engine/receipts.py delete mode 100644 antelab/engine/space.py delete mode 100644 antelab/engine/tick.py delete mode 100644 antelab/engine/types.py delete mode 100644 antelab/engine/valuation.py delete mode 100644 antelab/engine/world.py delete mode 100644 antelab/experiments/__init__.py delete mode 100644 antelab/experiments/compare.py delete mode 100644 antelab/experiments/orchestrator.py delete mode 100644 antelab/experiments/stats.py delete mode 100644 antelab/llm/__init__.py delete mode 100644 antelab/llm/client.py delete mode 100644 antelab/llm/narrative.py delete mode 100644 antelab/season_bundle.py delete mode 100644 cast/company/README.md delete mode 100644 cast/company/avery.yaml delete mode 100644 cast/company/ilya.yaml delete mode 100644 cast/company/mina.yaml delete mode 100644 docker-compose.yaml delete mode 100644 docs/README.md delete mode 100644 docs/plans/2026-04-29-company-emergence-design.md delete mode 100644 docs/research/2026-05-15-agent-trend-radar.md delete mode 100644 experiments/company-founder-duo.yaml delete mode 100644 experiments/company-genesis.yaml delete mode 100644 experiments/company-hostile-market.yaml delete mode 100644 experiments/company-late-shock.yaml delete mode 100644 experiments/company-market-boom.yaml delete mode 100644 experiments/company-rapid-growth.yaml delete mode 100644 experiments/company-remote-team.yaml delete mode 100644 experiments/company-skeleton-crew.yaml delete mode 100644 experiments/company-survival-gauntlet.yaml delete mode 100644 experiments/company-talent-war.yaml delete mode 100644 experiments/llm-benchmark.yaml delete mode 100644 frontend/Dockerfile delete mode 100644 frontend/PIXEL_QA_REPORT.md delete mode 100644 frontend/agent_debug.png delete mode 100644 frontend/e2e/fixtures/receipt-artifact.json delete mode 100644 frontend/e2e/receipt-first.spec.ts delete mode 100644 frontend/e2e/show-deck-demo.spec.ts delete mode 100644 frontend/eslint.config.js delete mode 100644 frontend/index.html delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/playwright.config.ts delete mode 100644 frontend/public/cast/company/portraits/.gitkeep delete mode 100644 frontend/public/demo/founder-duo-seed-55.json delete mode 100644 frontend/public/demo/gauntlet-seed-42.json delete mode 100644 frontend/public/demo/gauntlet-seed-99.json delete mode 100644 frontend/public/demo/genesis-seed-42.json delete mode 100644 frontend/public/demo/genesis-seed-77.json delete mode 100644 frontend/public/demo/hostile-market-seed-13.json delete mode 100644 frontend/public/demo/late-shock-seed-88.json delete mode 100644 frontend/public/demo/manifest.json delete mode 100644 frontend/public/demo/market-boom-seed-91.json delete mode 100644 frontend/public/demo/receipt-unsupported.json delete mode 100644 frontend/public/demo/remote-team-seed-33.json delete mode 100644 frontend/public/demo/talent-war-seed-77.json delete mode 100644 frontend/qa-checkpoints/A-global-map-idle-2026-04-13T02-35-37-869Z.png delete mode 100644 frontend/qa-checkpoints/A-global-map-idle-2026-04-13T02-37-49-343Z.png delete mode 100644 frontend/qa-checkpoints/A-global-map-idle-pass2-2026-04-13T02-39-22-689Z.png delete mode 100644 frontend/qa-checkpoints/B-district-focus-2026-04-13T02-37-58-736Z.png delete mode 100644 frontend/qa-checkpoints/B-district-focus-pass2-2026-04-13T02-39-29-265Z.png delete mode 100644 frontend/qa-checkpoints/C-agent-spotlight-pass2-2026-04-13T02-39-45-982Z.png delete mode 100644 frontend/qa-checkpoints/D-hud-readability-pass2-2026-04-13T02-39-50-267Z.png delete mode 100755 frontend/scripts/e2e-dev-stack.sh delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/ErrorBoundary.tsx delete mode 100644 frontend/src/agentVisualMovement.test.ts delete mode 100644 frontend/src/agentVisualMovement.ts delete mode 100644 frontend/src/appModel.ts delete mode 100644 frontend/src/artifactWorkbench.test.ts delete mode 100644 frontend/src/artifactWorkbench.ts delete mode 100644 frontend/src/bookmarkImport.test.ts delete mode 100644 frontend/src/bookmarkImport.ts delete mode 100644 frontend/src/cast/companyCast.ts delete mode 100644 frontend/src/components/AgentHaloLayer.tsx delete mode 100644 frontend/src/components/CastStrip.tsx delete mode 100644 frontend/src/components/DemoPlayer.tsx delete mode 100644 frontend/src/components/FloatingDetailCard.tsx delete mode 100644 frontend/src/components/IconDock.tsx delete mode 100644 frontend/src/components/MapAtmosphere.tsx delete mode 100644 frontend/src/components/NarrativeBar.tsx delete mode 100644 frontend/src/components/PixiWorldStage.tsx delete mode 100644 frontend/src/components/TopBar.tsx delete mode 100644 frontend/src/components/WorldViewport.tsx delete mode 100644 frontend/src/components/tools/ArtifactWorkbenchPanel.tsx delete mode 100644 frontend/src/components/tools/EventTimelinePanel.tsx delete mode 100644 frontend/src/components/tools/HistoryOpsPanel.tsx delete mode 100644 frontend/src/components/tools/ReplayStripPanel.tsx delete mode 100644 frontend/src/displayScalars.test.ts delete mode 100644 frontend/src/displayScalars.ts delete mode 100644 frontend/src/highlightMoments.test.ts delete mode 100644 frontend/src/highlightMoments.ts delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/observerShare.test.ts delete mode 100644 frontend/src/observerShare.ts delete mode 100644 frontend/src/pixelSpec.test.ts delete mode 100644 frontend/src/pixelSpec.ts delete mode 100644 frontend/src/primaryMapLocation.test.ts delete mode 100644 frontend/src/receiptSignals.test.ts delete mode 100644 frontend/src/receiptSignals.ts delete mode 100644 frontend/src/shareUtils.ts delete mode 100644 frontend/src/showDeckState.test.ts delete mode 100644 frontend/src/showDeckState.ts delete mode 100644 frontend/src/showFeedFormat.test.ts delete mode 100644 frontend/src/showFeedFormat.ts delete mode 100644 frontend/src/showIdentity.test.ts delete mode 100644 frontend/src/showIdentity.ts delete mode 100644 frontend/src/styles/cast-strip.css delete mode 100644 frontend/src/styles/components.css delete mode 100644 frontend/src/styles/effects.css delete mode 100644 frontend/src/styles/icon-dock.css delete mode 100644 frontend/src/styles/layout.css delete mode 100644 frontend/src/styles/narrative-bar.css delete mode 100644 frontend/src/styles/reset.css delete mode 100644 frontend/src/styles/theme-toggle.css delete mode 100644 frontend/src/styles/themes/brutalist.css delete mode 100644 frontend/src/styles/themes/control-room.css delete mode 100644 frontend/src/styles/themes/index.css delete mode 100644 frontend/src/styles/themes/theater.css delete mode 100644 frontend/src/styles/tokens.css delete mode 100644 frontend/src/styles/top-bar.css delete mode 100644 frontend/src/styles/typography.css delete mode 100644 frontend/src/styles/world-viewport.css delete mode 100644 frontend/src/useDemoReplay.test.ts delete mode 100644 frontend/src/useDemoReplay.ts delete mode 100644 frontend/src/useSimulationData.ts delete mode 100644 frontend/src/useTimelineBookmarks.ts delete mode 100644 frontend/src/useTimelineFilters.ts delete mode 100644 frontend/src/vite-env.d.ts delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/tsconfig.node.json delete mode 100644 frontend/ui-scan-before-gamepass.png delete mode 100644 frontend/ui-scan-evolution.gif delete mode 100644 frontend/ui-scan-evolution.mp4 delete mode 100644 frontend/ui-scan-pass1.png delete mode 100644 frontend/ui-scan-pass10.png delete mode 100644 frontend/ui-scan-pass11.png delete mode 100644 frontend/ui-scan-pass12.png delete mode 100644 frontend/ui-scan-pass13.png delete mode 100644 frontend/ui-scan-pass14.png delete mode 100644 frontend/ui-scan-pass15-assets.png delete mode 100644 frontend/ui-scan-pass17-pixi-density-fix.png delete mode 100644 frontend/ui-scan-pass18-pixi-organic-polygons.png delete mode 100644 frontend/ui-scan-pass19-road-tile-band.png delete mode 100644 frontend/ui-scan-pass2.png delete mode 100644 frontend/ui-scan-pass20-road-intersections.png delete mode 100644 frontend/ui-scan-pass21-road-junction-types.png delete mode 100644 frontend/ui-scan-pass23-road-endcaps.png delete mode 100644 frontend/ui-scan-pass3.png delete mode 100644 frontend/ui-scan-pass4.png delete mode 100644 frontend/ui-scan-pass5.png delete mode 100644 frontend/ui-scan-pass6.png delete mode 100644 frontend/ui-scan-pass7.png delete mode 100644 frontend/ui-scan-pass8.png delete mode 100644 frontend/ui-scan-pass9.png delete mode 100644 frontend/ui-scan.png delete mode 100644 frontend/vite.config.ts delete mode 100644 prompts/README.md delete mode 100644 scripts/aggregate_stats.py delete mode 100644 scripts/benchmark_llm.py delete mode 100644 scripts/compare_artifacts.py delete mode 100644 scripts/generate_demo_data.py delete mode 100755 scripts/restart.sh delete mode 100644 scripts/run_matrix.py delete mode 100644 seasons/company.yaml delete mode 100644 specs/001-spatial-graph.md delete mode 100644 specs/002-craft-primitive.md delete mode 100644 specs/003-reproducible-runs.md delete mode 100644 specs/004-persistence-save-load.md delete mode 100644 specs/005-longrun-stability.md delete mode 100644 specs/006-experiment-orchestrator.md delete mode 100644 specs/007-statistical-analysis.md delete mode 100644 specs/008-queryable-history-replay.md delete mode 100644 specs/009-history-ops-config.md delete mode 100644 specs/010-history-operations-panel.md delete mode 100644 specs/011-replay-ux-enhancements.md delete mode 100644 specs/012-replay-keyboard-shortcuts.md delete mode 100644 specs/013-living-world.md delete mode 100644 specs/014-timeline-event-filters.md delete mode 100644 specs/015-timeline-advanced-query-presets.md delete mode 100644 specs/016-timeline-event-detail-drawer.md delete mode 100644 specs/017-event-detail-structured-parsing.md delete mode 100644 specs/018-event-detail-context-links.md delete mode 100644 specs/019-event-investigation-shortcuts.md delete mode 100644 specs/020-event-bookmarks-and-investigation-path.md delete mode 100644 specs/021-bookmark-tags-and-groups.md delete mode 100644 specs/022-bookmark-import-export.md delete mode 100644 specs/023-investigation-set-versioning-and-import-conflict-resolution.md delete mode 100644 specs/028-persistent-event-store.md delete mode 100644 specs/029-artifact-workbench.md delete mode 100644 specs/030-statistical-inference-v2.md delete mode 100644 specs/031-large-run-operations.md delete mode 100644 specs/032-direct-broadcast-read.md delete mode 100644 specs/033-founder-company-seed.md delete mode 100644 specs/034-first-hires-and-role-claims.md delete mode 100644 specs/035-company-artifacts-and-knowledge-transfer.md delete mode 100644 specs/036-department-emergence-and-operating-rhythm.md delete mode 100644 specs/037-company-survival-gauntlet.md delete mode 100644 specs/038-antelab-show-deck-visual-system.md delete mode 100644 specs/039-agent-receipt-layer.md delete mode 100644 specs/README.md delete mode 100644 specs/TEMPLATE.md delete mode 100644 specs/archive/001-game-observer-ui.md delete mode 100644 specs/archive/001-observer-agent-free-walking.md delete mode 100644 specs/archive/001-real-llm.md delete mode 100644 specs/archive/002-tick-perception-snapshot.md delete mode 100644 specs/archive/003-unified-config.md delete mode 100644 specs/archive/004-observer-console.md delete mode 100644 specs/archive/005-narrative-simulator-layer.md delete mode 100644 specs/archive/006-sandbox-experiment-controls.md delete mode 100644 specs/archive/007-live-observer-stream-and-replay-strip.md delete mode 100644 specs/archive/012-agenttv-observer-ui-fit.md delete mode 100644 specs/archive/012-ui-simplification.md delete mode 100644 specs/archive/013-replay-comparison-panel.md delete mode 100644 specs/archive/observer-ui-hardcore.md delete mode 100644 tests/test_agent.py delete mode 100644 tests/test_api.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_experiments.py delete mode 100644 tests/test_llm.py delete mode 100644 tests/test_market.py delete mode 100644 tests/test_measurement.py delete mode 100644 tests/test_narrative.py delete mode 100644 tests/test_org.py create mode 100644 tests/test_package.py delete mode 100644 tests/test_pattern.py delete mode 100644 tests/test_receipts.py delete mode 100644 tests/test_season_bundle.py delete mode 100644 tests/test_space.py delete mode 100644 tests/test_tick.py delete mode 100644 tests/test_valuation.py delete mode 100644 tests/test_world.py diff --git a/.env.example b/.env.example deleted file mode 100644 index 45198de..0000000 --- a/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# ============================================================================= -# AnteLab Environment Variables -# Copy this file to .env and fill in your values. -# -# Override chain: code defaults → YAML config → environment variables -# Environment variables always win over YAML. -# ============================================================================= - -# --- Config file path (defaults to antelab/config/default.yaml) --- -# ANTELAB_CONFIG_PATH=experiments/company-survival-gauntlet.yaml -# ANTELAB_CONFIG_PATH=antelab/config/default.yaml - -# --- LLM settings --- -# ANTELAB_LLM_MODE=mock # mock | openai | anthropic -# ANTELAB_LLM_MODEL=gpt-4o-mini # Model name (ignored in mock mode) -# ANTELAB_LLM_TEMPERATURE=0.7 # Sampling temperature -# ANTELAB_LLM_MAX_TOKENS=512 # Max tokens per response - -# --- API keys (required when using real LLM providers) --- -# OPENAI_API_KEY=sk-... -# ANTHROPIC_API_KEY=sk-ant-... - -# --- Server settings --- -# ANTELAB_SERVER_HOST=127.0.0.1 # Bind address (0.0.0.0 for Docker) -# ANTELAB_SERVER_PORT=8000 # API port -# ANTELAB_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 493f294..4eedd1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,54 +7,19 @@ on: branches: [main] jobs: - backend: + kernel: runs-on: ubuntu-latest - timeout-minutes: 15 - + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 with: python-version: "3.12" - - uses: astral-sh/setup-uv@v5 with: enable-cache: true - cache-dependency-glob: "pyproject.toml" - - - name: Create venv and install deps - run: | - uv venv .venv --python 3.12 - uv pip install -e ".[dev]" --python .venv/bin/python - - - name: Ruff check - run: uv run ruff check antelab/ tests/ - - - name: Pytest - run: uv run pytest tests/ - - frontend: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Install deps - run: npm ci - working-directory: frontend - - - name: Typecheck - run: npm run typecheck - working-directory: frontend - - - name: Build - run: npm run build - working-directory: frontend + cache-dependency-glob: "uv.lock" + - run: uv sync --extra dev --frozen + - run: uv run ruff check antelab tests scripts + - run: uv run mypy antelab scripts + - run: uv run pytest -q diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml deleted file mode 100644 index 8db08f9..0000000 --- a/.github/workflows/frontend-ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Frontend CI - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - -jobs: - frontend: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install backend (for e2e stack) - run: | - python3 -m venv .venv - .venv/bin/pip install -U pip - .venv/bin/pip install -e ".[dev]" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - run: npm ci - working-directory: frontend - - - name: Install Playwright Chromium - run: npx playwright install --with-deps chromium - working-directory: frontend - - - name: Unit tests, build, lint - run: npm run test && npm run build && npm run lint - working-directory: frontend - - - name: Playwright e2e - run: npm run test:e2e - working-directory: frontend - - - name: Upload Playwright visual artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: frontend-playwright-artifacts - path: | - frontend/test-results/**/*.png - frontend/test-results/**/*.webm - frontend/test-results/**/*.zip - if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b9d7dc6..41c0e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,229 +1,18 @@ -# Node / Frontend -node_modules/ -frontend/dist/ -frontend/.tsbuild/ -.vite/ - -# Byte-compiled / optimized / DLL files +.venv/ __pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +*.py[cod] *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: .ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursor/ -.cursorignore -.cursorindexingignore - -# Local agent/tool metadata -.claude/ +.mypy_cache/ +.coverage +coverage.xml +dist/ +build/ +artifacts/ +benchmarks/*.json +.DS_Store .codex/ -.scratch/ -*.pid - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Local spec scratch files. Canonical specs/templates under specs/ are tracked. -specs/*.local.md - -# Playwright (frontend e2e) -frontend/test-results/ -frontend/playwright-report/ -frontend/blob-report/ +.claude/ .gstack/ +.superpowers/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/AGENTS.md b/AGENTS.md index 8c75b0e..bd7126b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,8 @@ -## Learned User Preferences +# AnteLab Agent Rules -- Prefers spec-first delivery: create or update specs before implementation and execute features against documented acceptance criteria. -- Prefers sequential execution of the recommended implementation order, continuing phase-by-phase with concise "next step" progression. -- Prefers uninterrupted execution through completion once implementation starts, rather than pausing between phases. -- Prefers repository changes to exclude local tool metadata directories from commits. -- Prefers a map-first game observer UI (pixel-art / indie / SimCity-style: districts and agent tokens on the main stage) where agent activity is visible live and agents freely move like people rather than standing idle, not only a data dashboard or cosmetic theme swap; expects readable scene density and reference-faithful composition while keeping the simulation experiment-first; for surrounding shell chrome, steers toward a dense "hardcore observation" deck (RimWorld-style: top unit strip, bottom log strip, side inspector rail) rather than generic dashboard layouts or purely decorative cute UI. -- Prefers real tile-or-sprite asset rendering for the observer map over pure CSS pseudo-pixel styling when the stack can support it, including permissively licensed open packs when bespoke art is not ready yet. Repeated feedback: shell/HUD-only styling passes rarely achieve a convincing pixel-game read on their own; cohesive map-layer tiles/sprites (and consistent pixel scaling in the viewport) are treated as the primary lever. Open to non-pixel map presentations (vector schematic, HD Canvas or Pixi without forced pixel scaling, or DOM-first modes) when they improve observability and product fit versus a retro game shell. -- Expects iterative frontend work to include periodic visual self-checks (for example scheduled Playwright scans or screenshots) to confirm the UI is moving toward the intended game-like direction. -- Expects complete long-run society functionality rather than narrow MVPs for core simulation improvements, including lifecycle dynamics such as births plus survival, resource, social, and environmental pressure systems that can sustain society beyond the initial agents. -- Prioritizes product-shape outcomes (watchable agents on screen, sharp three-second pitch, commercializable, GitHub-stars-friendly hook) over research/experiment framing when the two conflict; dislikes ambiguous or hedged positioning and has retired the "civilization laboratory" framing in favor of a live AnteLab show framing. Wants the thesis to read through a "Parallel Worlds" channel: the same AI cast under different physical rules, including speech vs silence, scarcity vs plenty, fog of war vs omniscience, and mortal vs immortal comparisons. -- Wants heterogeneous initialization and run-to-run variation; expects long-run order to emerge under constitution-style physics rather than identical scripted bootstraps. -- Prioritizes natural emergence over enforcing hand-authored social rules ("weak rules, strong nature") as the architectural direction. -- Expects agents to obey basic physical constraints in-world (for example preventing physically impossible resource operations). - -## Learned Workspace Facts - -- `RUNNING.md` is used as the canonical startup runbook for local and Docker workflows; `Makefile` provides standard shortcuts for setup, backend/frontend run, test, lint, and Docker operations. -- The incremental continual-learning index for this workspace is at `.cursor/hooks/state/continual-learning-index.json`. -- `DISCOVERIES.md` is the running log for dated, reproducible experiment findings aimed at readers outside day-to-day development. -- `make generate-posts COMPARE=` runs `scripts/generate_discovery_posts.py` to emit share-ready draft posts under `results/`. -- Default lifecycle timing in `antelab/config/default.yaml` should stay coherent with experiment expectations so births can appear in practical run windows. -- Local development now runs the backend on port `8080`; `make run-backend` and the frontend Vite `/api`/`/ws` proxies target `localhost:8080`. -- `antelab/api/server.py` uses a shared `asyncio.Lock` around `POST /api/tick`, `POST /api/save`, `POST /api/load`, and `GET /api/world` for consistent world reads and writes; `POST /api/save` writes `snapshot_version: 2` JSON with `agents_private` (per-agent `personality` and `memory_events`) for `POST /api/load` replay. Multiple API worker processes still need an external single-writer strategy. -- Canonical specs under `specs/` are now visible to git; only local scratch specs matching `specs/*.local.md` are ignored. -- Pixel art intake for the observer UI is documented under `frontend/public/assets/README.md` with a machine-readable source list in `frontend/public/assets/asset-sources.json`; downloaded textures are organized under `frontend/public/assets/terrain/`, `buildings/`, and `characters/`. -- The observer shell uses a `pixel-art-ui` class on the app root for pixel-shell CSS overrides; the right **show deck** (HUD label **Deck**; legacy code may still say "director") open/closed state persists in `localStorage` as `antelab.directorPanelOpen` (`1` / `0`). When focus is not in an input, textarea, or contenteditable, the `]` key toggles that rail (same as the HUD control). -- The project is now positioned as **AnteLab**: a watchable Parallel Worlds / company-shock observer layer over the AnteLab engine. Do not reintroduce old single-scenario survival branding as the default product frame; default local runs should focus on `experiments/company-survival-gauntlet.yaml` unless a task explicitly targets another scenario. -- `docs/plans/2026-04-25-long-run-emergence-design.md` is the confirmed technical design for the complete Long-Run Society Engine, covering four official pressure presets, dynamic world systems, physical primitives, measurement artifacts, and benchmark phases. \ No newline at end of file +- Read the approved reboot design and current implementation plan before edits. +- Preserve deterministic integer state and simulation-owned randomness. +- Do not add LLM calls, free-form actions, company simulation, or receipt product + behavior to the V1 core. +- Use tests first and explicit file staging; never use `git add .`. +- Treat extinction as a valid result and broken invariants as engine failures. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 73ee56e..eeaa8eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,330 +1,10 @@ # Architecture -## AnteLab on a reproducible simulation core - -**AnteLab** is the audience-facing observer product: a watchable, map-first view -of LLM agents in a shared world with real scarcity and no scripted outcomes. - -**AnteLab** (this repository’s Python package and engine) is the controlled -experiment platform underneath: the Constitution defines a **baseline** set of -physics (the control group); experiments vary one or more axioms and observe -the results. The same runs are replayable, snapshot-loadable, and comparable -across configurations. - -The two are one stack: physics honesty and reproducibility live in the engine; -presentation and narrative framing live in the observer and shareable -artifacts—not as a second ruleset. - -**One-line contract:** given the same seed, experiment config, and tick order, you -get the same simulation outcomes; AnteLab displays and records—it does not -re-resolve actions or override `world.axioms`. - -See [CONSTITUTION.md](CONSTITUTION.md) for the baseline axioms. -See [EXPERIMENTS.md](EXPERIMENTS.md) for the experiment catalog and methodology. - -This document owns system contracts and module responsibilities. Product pitch -belongs in [README.md](README.md), run commands in [RUNNING.md](RUNNING.md), -feature requirements in [specs/](specs/), and multi-phase plans in -[docs/](docs/). - -## Physics, Not Laws - -The core principle: the engine is a **physics simulator**, not a **rule book**. - -``` -What the engine hardcodes (physics): What agents invent (culture): -───────────────────────────────────── ────────────────────────────── -Agents occupy locations Property rights -Perception scope (tunable) Laws and governance -Resources are conserved Trade and economy -Actions cost time (1 per tick) Morality and norms -Communication reach (tunable) Relationships and trust -Taking an object changes possession Theft (the concept) -Inventory tracks who holds what Ownership (the concept) -``` - -## Engine Layers - -The engine separates three independently toggleable layers: - -``` -┌─────────────────────────────────────────────────────────┐ -│ Social Tracking (optional) │ -│ trust_by_agent · obligation_by_agent · role_claims │ -│ Enabled via: axioms.social_tracking = true │ -├─────────────────────────────────────────────────────────┤ -│ Biology (optional) │ -│ hunger · fatigue · stress · aging · disease │ -│ reproduction · auto_eat │ -│ Configured via: lifecycle params + axioms │ -├─────────────────────────────────────────────────────────┤ -│ Physics (core, always on) │ -│ location · inventory · action resolution · events │ -│ move · say · give · take · examine · rest │ -└─────────────────────────────────────────────────────────┘ -``` - -When the Social Tracking layer is disabled (`axioms.social_tracking = false`), -primitives like `give` and `take` perform only their physical effect (item -transfer) without any side-effect bookkeeping. This allows experiments to test -whether social constructs emerge from agent behavior alone. - -When mortality is disabled (`axioms.mortality = false`), agents never die, -allowing study of long-lived societies. - -## System Overview - -``` -┌─────────────────────────────────────────────────────────┐ -│ Frontend (TS) │ -│ Real-time observation UI │ -│ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ React UI Layer │ │ -│ │ TopBar · IconDock · FloatingDetailCard · keyboard │ │ -│ └───────────────────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ Pixi Render Layer │ │ -│ │ PixiWorldStage — districts · roads · agent tokens │ │ -│ └───────────────────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ CSS Atmosphere Layer │ │ -│ │ MapAtmosphere · AgentHaloLayer · world→screen │ │ -│ └───────────────────────────────────────────────────┘ │ -└──────────────────────┬──────────────────────────────────┘ - │ WebSocket / REST -┌──────────────────────┴──────────────────────────────────┐ -│ API Layer (Python) │ -│ FastAPI · WebSocket broadcast │ -│ GET /api/experiment → axioms + measurements │ -└──────────────────────┬──────────────────────────────────┘ - │ in-process calls -┌──────────────────────┴──────────────────────────────────┐ -│ Simulation Engine (Python) │ -│ │ -│ ┌─────────┐ ┌─────────┐ ┌──────────────────┐ │ -│ │ World │ │ Agents │ │ Tick Runner │ │ -│ │ (physics)│◄──┤ (N) │──►│ (event loop) │ │ -│ └─────────┘ └────┬────┘ └───────┬──────────┘ │ -│ │ │ │ -│ ┌─────┴─────┐ ┌─────┴──────────┐ │ -│ │ LLM Client│ │ Experiment │ │ -│ │ (async) │ │ Observer │ │ -│ └───────────┘ └────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### Frontend Architecture Pattern - -AnteLab's observer is a **map-first Living World dashboard** (spec 013): - -- **PixiJS owns the world stage**: data-topology map rendering, agent tokens, event - pulses, camera-centric map interactions. -- **React owns product UI**: TopBar status line, IconDock (collapsible right-edge - panel with Agents/Events/Stats tabs), FloatingDetailCard (glassmorphism agent - detail on the map), keyboard shortcut handling. -- **CSS owns atmosphere**: MapAtmosphere (district glow, grid, vignette), - AgentHaloLayer (faction-colored pulsing rings), positioned via world→screen - coordinate transform matching the PixiJS camera. -- **Data contracts stay backend-first**: all layers consume the same world/tick - snapshots and event streams from FastAPI endpoints and WebSocket broadcast. - -The observer is a lens, not a second ruleset. Its job is to make agent behavior -legible, shareable, and trustworthy without adding decorative fiction. - -### Data-First Observer Design Pillars - -The frontend is a professional observation instrument. Every visual element must -earn its place by surfacing real simulation state. - -1. **Experiment-first truth** - - Every visual element must map to real simulation state. - - No fabricated events, outcomes, or hidden rule overrides in the renderer. -2. **Map-first readability** - - The world map is the primary stage: a live data topology, not a game level. - - Agent activity, proximity, movement, and hotspots should be legible at a glance. -3. **Data-first, not game-like** - - The world map renders data geometries (nodes, edges, heat overlays), not - decorative terrain. - - Agent tokens are minimal identifiers (monograms, status badges, activity - indicators), not character sprites. - - Motion communicates state transitions, not ambient animation. -4. **Reproducible visual playback** - - Replay and compare views must render the same run artifacts deterministically. - - Visual transitions may be smoothed, but underlying state order cannot be changed. -5. **Dual-layer frontend contract** - - React: controls, analysis, timeline, filters, compare workflows. - - Canvas: data-topology world map, agent identifiers, event emphasis, camera. -6. **Progressive enhancement** - - Ship clarity first (state visibility, action legibility), then polish - (trajectory trails, heat map interpolation, camera easing). - - Effects must never obscure experiment signals. - -### Observer Truth Labeling - -The interface is a monitoring instrument, not a game renderer: - -- Engine-backed values (tick, event log, agent location/inventory, run metrics) - are primary truth surfaces — prominent, precise, unadorned. -- Decorative effects (scanlines, CRT simulation, terrain skinning) have no place - in a data observer. Every pixel should communicate state or afford interaction. -- Synthetic visual projections that do not map to simulation data must not appear. - -## Module Map - -### Modules - - -| Module | File | Responsibility | -| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `World` | `antelab/engine/world.py` | Physics engine: holds state, resolves free-form intents against physical primitives. Respects experiment axioms. | -| `ExperimentAxioms` | `antelab/engine/world.py` | Tunable physics dimensions for experiments (perception, communication, social tracking, mortality) | -| `Agent` | `antelab/engine/agent.py` | Owns personality, memory; runs perceive-decide-act cycle with free-form intent | -| `TickRunner` | `antelab/engine/tick.py` | Advances simulation by one step: iterates agents, collects actions, applies results, records measurements | -| `ExperimentObserver` | `antelab/engine/measurement.py` | Records per-tick metrics (action distribution, resource flow, colocation, cooperation) without affecting simulation | -| `LLMClient` | `antelab/llm/client.py` | Async wrapper around LLM APIs with mock mode for development | -| `API Server` | `antelab/api/server.py` | FastAPI app exposing simulation state, control endpoints, and experiment measurements | -| `Config` | `antelab/config/` | YAML-based configuration for world, agents, LLM, and experiment axioms | - -### Frontend Components - -| Component | File | Responsibility | -|-----------|------|---------------| -| `App` | `frontend/src/App.tsx` | Root: wires data flow, camera state, keyboard shortcuts, faction assignment | -| `TopBar` | `frontend/src/components/TopBar.tsx` | 32px status bar: tick, signal, agent count, zone count, REC indicator | -| `WorldViewport` | `frontend/src/components/WorldViewport.tsx` | Map container: orchestrates PixiJS canvas + CSS atmosphere + halo + detail layers | -| `PixiWorldStage` | `frontend/src/components/PixiWorldStage.tsx` | PixiJS canvas: districts, roads, agent tokens, event pulses, camera | -| `IconDock` | `frontend/src/components/IconDock.tsx` | Collapsible right-edge panel: 44px icon bar → 280px tabbed panel (Agents/Events/Stats) | -| `MapAtmosphere` | `frontend/src/components/MapAtmosphere.tsx` | CSS overlays: district glow (agent-count intensity), grid, edge vignette | -| `AgentHaloLayer` | `frontend/src/components/AgentHaloLayer.tsx` | CSS animated halos on agent markers + `worldToScreen()` coordinate transform | -| `FloatingDetailCard` | `frontend/src/components/FloatingDetailCard.tsx` | Glassmorphism agent detail card positioned on the map | -| `appModel` | `frontend/src/appModel.ts` | Type definitions: `WorldState`, `AgentInfo`, event classification | -| `pixelSpec` | `frontend/src/pixelSpec.ts` | Camera contract: zoom levels, pan snapping, coordinate transforms | -| `useSimulationData` | `frontend/src/useSimulationData.ts` | WebSocket hook: live world state, replay controls, stream status | - - -### Data Flow - -``` - ┌──────────────────────────────────────────────────────────┐ - │ TickRunner.run() │ - │ │ - │ observer.begin_tick(world.tick) │ - │ │ - │ for each agent: │ - │ 1. perception = agent.perceive(world) │ - │ → scope controlled by axioms.perception │ - │ 2. action = agent.decide(perception) ──► LLM │ - │ → free-form intent: { verb, parameters } │ - │ 3. result = world.apply(action) │ - │ → resolved against physical primitives │ - │ → social tracking conditional on axioms │ - │ 4. observer.record_action(action, result) │ - │ 5. agent.remember(result) │ - │ │ - │ observer.record_locations(...) │ - │ observer.end_tick() │ - │ world.tick += 1 │ - └──────────────────────────────────────────────────────────┘ -``` - -### Key Types - -```python -@dataclass -class Action: - """A free-form intent expressed by an agent.""" - agent_id: str - verb: str # any verb — no fixed menu - parameters: dict[str, Any] # target, destination, message, item, etc. - reasoning: str # LLM's chain-of-thought - -@dataclass -class Perception: - """What an agent observes (scope controlled by axioms).""" - tick: int - location: str - nearby_agents: list[str] # local or global, depending on axiom - nearby_items: list[str] - recent_events: list[str] - -@dataclass -class ActionResult: - """Outcome after the world resolves an intent against physics.""" - success: bool - description: str - events: list[str] - state_changes: dict[str, Any] - -@dataclass -class ExperimentAxioms: - """Tunable physics for civilization experiments.""" - perception: str # "local" | "global" - communication: str # "colocated" | "broadcast" | "silent" - social_tracking: bool # engine tracks trust/obligation? - auto_eat: bool # engine auto-consumes food? - mortality: bool # agents can die? - memory_size: int # agent memory buffer size -``` - -### Physical Primitives - -The World resolves free-form verbs by mapping them to physical primitives. -Synonyms are supported (e.g., "walk" -> `move`, "grab" -> `take`). -Unknown verbs fail as "physically unresolvable", not "invalid". - - -| Primitive | Effect | Affected by axioms/config | -| --------------------- | ------------------------------------------- | ------------------------------------------------------------ | -| `move(destination)` | Agent changes location | Location graph adjacency | -| `say(message)` | Message logged, visible to nearby agents | `communication`: disabled in "silent" mode | -| `give(target, item)` | Item moves from agent to target | `social_tracking`: trust/obligation side-effects conditional | -| `take(source, item)` | Item moves from source/location to agent | `social_tracking`: trust/obligation side-effects conditional | -| `harvest(resource)` | Location resource moves into inventory | Resource zones and local availability | -| `store(item)` | Inventory item moves into local storage | Resource decay/storage config | -| `build_shelter(...)` | Materials become a location shelter feature | Environment/pressure config | -| `consume(item)` | Inventory food reduces hunger | Lifecycle parameters and `auto_eat` | -| `treat(target, item)` | Medicine reduces disease pressure | Disease pressure config | -| `reproduce(target)` | Eligible agents may start pregnancy | Lifecycle parameters and `mortality` | -| `examine(target)` | Agent reads public state of target | `perception`: target scope varies | -| `rest()` | Agent does nothing and recovers needs | Lifecycle parameters | -| `craft(recipe)` | Inputs transform into outputs | `world.recipes` and resource conservation | - - -Any higher-level concept ("trade", "vote", "arrest", "marry") is a **multi-agent protocol** -that agents coordinate through communication, not a built-in engine feature. - -### Experiment Infrastructure - -Experiments are defined as YAML configs in `experiments/`: - -``` -experiments/ - company-genesis.yaml # founder starts alone, hires from talent pool - company-survival-gauntlet.yaml # 5-shock resilience test -``` - -Each config extends `antelab/config/default.yaml`. The `ExperimentObserver` collects -measurements automatically during each tick run, accessible via -`GET /api/experiment`. - -### Known Simplifications - - -| Constitution says | Current implementation | Impact | -| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| Art. III transparent-world discussions assume rich global observability | `perception=global` currently exposes global agent identity; richer details still come from events + local interactions | Transparent-world runs are meaningful, but "perfect observability" scenarios are still a partial implementation | -| Experiment analysis should surface communication/cooperation shifts | Artifact comparison now includes observer-derived behavior metrics, but still uses run-level aggregates | Counterfactual deltas are more visible than before, but sequence-level causal analysis remains future work | -| Long-run persistence can be queried as a store | Current persistence uses JSON snapshot history plus a passive SQLite `EventStore` for snapshots/events | Good replay and recap baseline; full event-sourced replay and advanced event search remain future work | - - -### Design Decisions - -- **Synchronous tick model**: All agents act within a single tick before the world advances. -- **Mock-first LLM**: `LLMClient` defaults to mock implementation. Real API calls are opt-in via config. -- **Flat state with snapshot persistence**: Core world state stays in-memory dataclasses, with JSON snapshot history/save-load for reproducibility. -- **Action validation by World**: Agents propose free-form intents; the World resolves them against physics. -- **Config-driven agents**: Agent definitions live in YAML, not code. -- **Experiment-first architecture**: Every physical behavior is toggleable via axiom configuration. The engine is designed for controlled experiments, not a single fixed simulation. -- **Measurement without interference**: The `ExperimentObserver` collects data passively, never affecting simulation behavior. - +The headless Python engine is authoritative. Organisms receive bounded local +sensors, emit bounded effectors, and inherit fixed-point genomes. Rendering and +analysis never feed state back into the engine. + +The implementation contract is defined by +`docs/superpowers/specs/2026-07-10-digital-evolution-core-reboot-design.md`. +The first implementation slice is tracked by +`docs/superpowers/plans/2026-07-10-deterministic-evolution-kernel.md`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a0771d8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,106 +0,0 @@ -# CLAUDE.md - -This file is the short operating contract for AI agents working in this repo. -Do not duplicate the full product pitch or architecture here; link to the -canonical documents instead. - -## Read Order - -1. [README.md](README.md) - product shape and quick start. -2. [CONSTITUTION.md](CONSTITUTION.md) - baseline physics and hard constraints. -3. [ARCHITECTURE.md](ARCHITECTURE.md) - system contracts and module map. -4. [CONTRIBUTING.md](CONTRIBUTING.md) - workflow, tests, and spec rules. -5. [RUNNING.md](RUNNING.md) - current run commands and ports. - -## Non-Negotiable Engine Rules - -The World is a physics engine, not a game master. - -1. Agents express free-form intents through `verb` + `parameters`; do not add - hardcoded action menus. -2. Unknown verbs fail as physically unresolvable, not morally invalid. -3. Engine code checks physical possibility only; never enforce politeness, - fairness, property law, leadership legitimacy, or trade contracts. -4. Do not leak global information unless the active experiment axiom explicitly - enables global perception. -5. Resources are conserved unless a config explicitly models natural - regeneration or decay. -6. Physical behaviors that affect experiments must be controlled by config or - `world.axioms`. -7. Measurement and observer UI are passive; they must not alter simulation - outcomes. - -## Product/Engine Split - -- **AnteLab** is the watchable show layer: map, cast, recap, shareable observer - experience. -- **AnteLab** is the Python simulation core: reproducible runs, physical - primitives, experiment axioms, artifacts, and measurements. - -The observer can dramatize real state, but it must not invent events or become a -second ruleset. - -## Spec-First Workflow - -Every feature starts with a spec in `specs/NNN-title.md` using -`specs/TEMPLATE.md`. - -1. Write or update the spec. -2. Check Constitution and Architecture alignment. -3. Write tests from the acceptance criteria. -4. Implement the narrow behavior described by the spec. -5. Run verification. -6. Update status/docs only where the canonical contract changed. - -## Verification - -Use Makefile targets unless debugging something narrower: - -```bash -make test -make lint -cd frontend && npm run typecheck -cd frontend && npm run build -``` - -For frontend visual changes, include a browser/screenshot pass when practical. -For experiment changes, include a matrix or long-run smoke when practical: - -```bash -make run-matrix-smoke -make long-run-smoke -``` - -## Documentation Ownership - -- `README.md`: public entry, quick start, document map. -- `RUNNING.md`: canonical local/Docker runbook. -- `CONTRIBUTING.md`: contributor workflow and how-to guidance. -- `CONSTITUTION.md`: baseline physics axioms only. -- `ARCHITECTURE.md`: module contracts, data flow, known simplifications. -- `EXPERIMENTS.md`: methodology and catalog. -- `DISCOVERIES.md`: dated, reproducible findings. -- `ROADMAP.md`: current status and next priorities. -- `SPEC_STATUS.md`: implemented specs mapped to code and tests. -- `docs/`: deeper plans and design notes. -- `specs/`: feature specs and template. - -When a fact belongs in one canonical place, link to it rather than restating it. - -## Skill routing - -When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. - -Key routing rules: -- Product ideas/brainstorming → invoke /office-hours -- Strategy/scope → invoke /plan-ceo-review -- Architecture → invoke /plan-eng-review -- Design system/plan review → invoke /design-consultation or /plan-design-review -- Full review pipeline → invoke /autoplan -- Bugs/errors → invoke /investigate -- QA/testing site behavior → invoke /qa or /qa-only -- Code review/diff check → invoke /review -- Visual polish → invoke /design-review -- Ship/deploy/PR → invoke /ship or /land-and-deploy -- Save progress → invoke /context-save -- Resume context → invoke /context-restore diff --git a/CONSTITUTION.md b/CONSTITUTION.md deleted file mode 100644 index 1db35a8..0000000 --- a/CONSTITUTION.md +++ /dev/null @@ -1,194 +0,0 @@ -# AnteLab Constitution - -This document defines the **baseline physical axioms** of the AnteLab simulation. -These axioms describe the default physics — the "control group" for experiments. -Each axiom can be varied to study how changes in physics affect agent behavior. - -Think of this as physics, not law. Physics says "objects fall"; law says "don't steal". -AnteLab hardcodes physics. Agents invent laws. Experiments change the physics. - -See [EXPERIMENTS.md](EXPERIMENTS.md) for the experiment catalog and methodology. - -This document owns baseline axioms. Do not add feature plans, run commands, or -UI/product copy here; link to the relevant canonical document instead. - ---- - -## Article I — Existence - -1. An **Agent** is the atomic unit of the simulation. Each agent has: - - An identity (unique, permanent) - - A location in the world (exactly one at any time) - - An internal state (personality, memory, needs) that only the agent itself can read - - A public state (name, location, visible actions) that other agents can observe -2. The **World** is the shared substrate. It holds: - - A spatial graph of locations - - A registry of all agents and their public states - - A global event log (append-only, agents can read but not edit) - - Shared resources (objects, tokens) that exist at locations - -## Article II — Time - -1. Time is discrete. The world advances in **ticks**. -2. Each tick, every living agent gets exactly **one action**. -3. All agents perceive the world state as it was at the **start** of the tick. - No agent sees another agent's action from the same tick. -4. Actions are resolved in a deterministic order (by agent ID). - -## Article III — Perception - -1. Agents can only perceive what is **local**: agents and events at their current location. -2. Agents can perceive the **public state** of nearby agents, never their internal state. -3. Agents remember what they have personally perceived. Memory is private and finite. -4. There is no global broadcast. Information travels only through agent-to-agent communication - and physical co-presence. - -## Article IV — Agency - -1. An agent's action is a **free-form intent** expressed as: - - A verb (what the agent wants to do) - - Parameters (targets, content, quantities) - - The agent has no predefined action menu. It can attempt anything. -2. The World resolves the intent against physical constraints: - - Can the agent physically do this? (e.g., is the target present? does the agent have the resource?) - - What changes in the world as a result? - - What do nearby agents observe? -3. The World never judges intent as "moral" or "immoral". It only checks physical possibility. - Whether an action is "good" or "bad" is for other agents to decide. - -## Article V — Communication - -1. Speech is an action. An agent can say anything during its turn. -2. Speech is heard by all agents at the same location. -3. There is no enforced truthfulness. Agents can lie. -4. Agreements between agents are not enforced by the World. - If Alice promises Bob something, only social pressure (from other agents) holds her to it. - -## Article VI — Resources - -1. Physical resources are conserved. Creating something requires inputs. -2. Resources exist at locations. To interact with a resource, an agent must be at its location. -3. Ownership is a social construct. The World tracks possession (who holds what), - but "ownership" and "property rights" are concepts agents must invent and enforce themselves. - -## Article VII — Life Cycle - -1. Agents are born, age, and die. These events are governed by configurable parameters. -2. The World enforces biological constraints (hunger, fatigue, aging) as physical realities. -3. Social constructs around life events (funerals, inheritance, birth celebrations) - are for agents to create. - -## Article VIII — Emergence - -1. **Laws** do not exist in the engine. Agents may propose rules, vote on them, and choose to - follow or break them. Enforcement is social, not mechanical. -2. **Economy** is not built-in. If agents want to trade, they coordinate through communication. - The World only processes the physical act of transferring resources. -3. **Relationships** are not tracked by the World. They exist in agent memory and behavior. -4. **Culture** is whatever patterns of behavior agents develop and transmit. - -## Article IX — What the Engine Must NOT Do - -1. Must not hardcode a fixed set of allowed actions. Actions are free-form intents. -2. Must not enforce social rules (politeness, fairness, honesty). -3. Must not create global information that wasn't physically transmitted. -4. Must not give any agent privileged access to world state. -5. Must not make moral judgments about agent behavior. -6. Must not prevent agents from attempting "bad" actions — only from physically impossible ones. - ---- - -## The Constitution as Experiment - -The nine articles above define the **baseline** — the default physics that -produce a "control group." Every article represents one or more tunable -dimensions: - - -| Article | Dimension | Baseline | Example Variations | -| ------- | ------------------- | --------------------- | ------------------------------------- | -| III | Perception scope | Local (co-located) | Global, radius-N, selective | -| V | Communication reach | Co-located speech | Broadcast, silent, written/persistent | -| VI | Resource dynamics | Conserved | Regenerating, infinite, decaying | -| IV | Action freedom | Free-form verbs | Restricted menu, pre-committed | -| II | Time model | Synchronous, 1 action | Async, multi-action | -| VII | Mortality | Agents die | Immortal, fragile, rebirth | -| III.3 | Memory capacity | 50-event buffer | Amnesiac (1), unlimited, shared | - - -An experiment changes one or more dimensions while holding the rest at -baseline. The interesting results are: - -- **Absences**: behaviors present in baseline that vanish under variation -- **Persistence**: behaviors that survive despite axiom changes -- **Novelty**: behaviors that appear only under variation - -See [EXPERIMENTS.md](EXPERIMENTS.md) for the full experiment catalog. - ---- - -## Implications for Implementation - -### The World is a physics engine, not a game master. - -The `apply_action` method should work like this: - -``` -Agent intent: "I want to take Bob's apple" - -World checks: - - Is Bob at the same location? → Yes/No - - Does Bob have an apple? → Yes/No - - Can the agent physically reach it? → Yes/No - -World does NOT check: - - Is this stealing? (social concept — not the World's job) - - Is this allowed by community rules? (agents enforce their own rules) - - Will Bob be upset? (Bob decides that himself next tick) - -If physically possible → execute and log the event -If physically impossible → reject with reason -``` - -### Action types are emergent, not enumerated. - -Instead of `action_type in ["speak", "move", "wait", "interact"]`, -the engine should interpret free-form verbs and resolve them against physical primitives: - - -| Physical primitive | What it means | -| -------------------- | -------------------------------------------- | -| `move(destination)` | Change agent's location | -| `say(message)` | Broadcast text to co-located agents | -| `give(target, item)` | Transfer a resource to another agent | -| `take(target, item)` | Attempt to take a resource (may be resisted) | -| `examine(target)` | Read the public state of a nearby entity | -| `rest()` | Do nothing, recover energy | - - -> **Future primitives**: The table above is not exhaustive. New physical primitives -> (e.g., `craft(inputs, output)` for resource transformation) can be added as the -> simulation grows, as long as they obey Article VI (resource conservation) and -> Article IX (no moral judgments). Adding a primitive is an engine extension, not -> a Constitution amendment. - -An agent saying "I want to trade my wheat for Bob's iron" gets decomposed into -`give(Bob, wheat)` + a social expectation that Bob will `give(self, iron)` next tick. -The World does not enforce the trade — Bob might just keep the wheat and walk away. - -### Engine layers - -The engine separates three concerns, each independently toggleable: - - -| Layer | Responsibility | Can be disabled? | -| ------------------- | --------------------------------------------- | -------------------- | -| **Physics** | Location, inventory, action resolution | No (core) | -| **Biology** | Hunger, fatigue, aging, disease, reproduction | Yes (per experiment) | -| **Social Tracking** | Trust, obligation, role claims | Yes (per experiment) | - - -When the Social Tracking layer is disabled, primitives like `give` and `take` -perform only their physical effect (item transfer) without any side-effect -bookkeeping. This allows experiments to test whether social constructs emerge -from agent behavior alone. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93df129..3f2a672 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,135 +1,8 @@ # Contributing -## Before You Code +Every behavior change starts with a failing test. Authoritative simulation code +must use integer state, AnteLab-owned RNG streams, and versioned serialization. +Do not introduce LLM or network dependencies into the core organism loop. -Read these in order: -1. [CONSTITUTION.md](CONSTITUTION.md) — baseline physical axioms -2. [ARCHITECTURE.md](ARCHITECTURE.md) — system design and known simplifications -3. [CLAUDE.md](CLAUDE.md) — short AI-agent operating contract -4. [RUNNING.md](RUNNING.md) — current run commands and ports - -The single most important rule: **the World is a physics engine, not a game master.** -If your change makes the engine judge morality, enforce social rules, or hardcode -an action menu — it violates the Constitution. - -## Development Setup - -```bash -make setup -``` - -Use [RUNNING.md](RUNNING.md) for the full local and Docker runbook. - -## Workflow: Spec-First Development - -**No code without a spec.** Every feature follows this sequence: - -1. **Write a spec** in `specs/NNN-title.md` using `specs/TEMPLATE.md` - - What problem does this solve? - - What changes? (API, data structures, config) - - Acceptance criteria (testable conditions) - - Constitution compliance check -2. **Review the spec** — does it align with Constitution and Architecture? -3. **Write tests** based on the spec's acceptance criteria -4. **Write code** — implement exactly what the spec describes -5. **Verify**: `.venv/bin/python -m pytest tests/ -v` (all pass) + `.venv/bin/python -m ruff check antelab/ tests/` (zero errors) -6. **Update docs** — only the canonical documents whose contract changed -7. **Commit** in English with a clear message, referencing the spec number - -Use [SPEC_STATUS.md](SPEC_STATUS.md) to map completed specs to implementation -files and tests. Do not duplicate full design plans there. - -## How to Add a New Physical Primitive - -Example: adding a `craft` primitive. - -### Step 1: Add the resolver method to `World` - -In `antelab/engine/world.py`, add a method following the existing pattern: - -```python -def _resolve_craft(self, agent: AgentState, action: Action) -> ActionResult: - # 1. Extract parameters from action.parameters - # 2. Check physical possibility (agent has inputs? recipe exists?) - # 3. Mutate state (remove inputs, add outputs) - # 4. Log event to self.event_log - # 5. Return ActionResult - ... -``` - -### Step 2: Register in the `_PRIMITIVES` dict - -At the bottom of `world.py`, add the verb and any aliases: - -```python -_PRIMITIVES: dict[str, Any] = { - ... - "craft": World._resolve_craft, - "make": World._resolve_craft, - "build": World._resolve_craft, -} -``` - -### Step 3: Write tests - -In `tests/test_world.py`, add tests covering: -- Successful case -- Missing inputs (physical impossibility) -- Alias works - -### Step 4: Update documentation - -- `ARCHITECTURE.md` → Physical Primitives table -- `CONSTITUTION.md` → only if the primitive introduces a new *axiom* (rare) - -### Checklist - -- [ ] Resolver only checks **physical** possibility, never social rules -- [ ] Resources are conserved (Article VI) -- [ ] Events are logged so nearby agents can perceive the outcome -- [ ] Aliases added for natural language synonyms -- [ ] Tests pass, lint clean - -## How to Add a New LLM Provider - -### Step 1: Implement in `LLMClient` - -In `antelab/llm/client.py`, add a branch in the `complete` method: - -```python -async def complete(self, prompt: str, **kwargs: Any) -> str: - if self.mode == "mock": - return self._mock_response(prompt) - if self.mode == "openai": - return await self._openai_complete(prompt, **kwargs) - raise NotImplementedError(...) -``` - -### Step 2: Add config support - -The `llm` section in `default.yaml` already has `mode`, `model`, -`temperature`, `max_tokens`. Use `self.kwargs` for provider-specific settings. - -### Step 3: Environment variables - -Add the API key to `.env.example` with a comment. Never commit real keys. - -### Step 4: Test - -Add a test that verifies the provider raises `NotImplementedError` when -the API key is missing, and a mock/integration test if possible. - -## Code Style - -- Python: type hints everywhere, `ruff` as linter, `mypy` for static checking -- TypeScript: strict mode, no `any` without justification -- Comments: explain *why*, not *what*. Don't narrate code. -- English: all code, comments, commit messages, and docs - -## Documentation Hygiene - -- Link to canonical docs instead of repeating long explanations. -- Put feature requirements in `specs/`, not `README.md`. -- Put multi-phase plans in `docs/plans/`, not `SPEC_STATUS.md`. -- Update `CONSTITUTION.md` only when baseline axioms change. -- Update `ARCHITECTURE.md` when module contracts or data flow change. +Run `make verify` before submitting a change. Generated artifacts are ignored; +small deterministic fixtures may be committed with their reproduction command. diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 1b12c34..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,165 +0,0 @@ -# Design System — AnteLab Witness - -Canonical design contract for the AnteLab observer frontend. All visual decisions -flow from this file. Deviations are bugs unless this file is updated first. - -## Classifier - -**APP UI** — workspace-driven, data-dense simulation monitor. Dark, warm, -terminal-inspired. Calm surface hierarchy, strong typography, few colors. - -## Fonts - -| Token | Stack | Role | -|-------|-------|------| -| `--w-font-display` | Instrument Serif, Georgia, serif | Agent names, major headings | -| `--w-font-body` | Instrument Sans, system-ui, -apple-system, sans-serif | Body text, UI labels | -| `--w-font-mono` | IBM Plex Mono, SF Mono, Cascadia Code, monospace | Data, stats, metadata, code | - -- **Max 3 font families.** Do not add a fourth. -- **No default stacks.** Inter, Roboto, Open Sans, Arial are banned. -- `font-display: swap` on all web fonts. Preconnect to `fonts.googleapis.com` and `fonts.gstatic.com`. - -### Type Scale - -| Token | Size | Usage | -|-------|------|-------| -| `--w-text-xs` | 10px | Labels, metadata, top bar stats | -| `--w-text-sm` | 11px | Secondary text, button labels, captions | -| `--w-text-base` | 13px | Body text, empty states | -| `--w-text-md` | 16px | Agent names, emphasized body | -| `--w-text-lg` | 22px | Section headers | -| `--w-text-xl` | 30px | Detail view titles | -| `--w-text-2xl` | 42px | Page titles (reserved) | - -- Line height: 1.5 for body, 1.1-1.2 for display text. -- Body text must not drop below 13px. -- Labels must not drop below 10px. - -## Color Palette - -Warm, earthy dark theme. All colors defined as CSS custom properties in `tokens.css`. - -### Surfaces -| Token | Value | Usage | -|-------|-------|-------| -| `--w-bg-deep` | `#141210` | Viewport background, deepest level | -| `--w-bg-surface` | `#1c1a16` | Panels, cards, buttons | -| `--w-bg-elevated` | `#24211c` | Overlays, detail views | - -### Text -| Token | Value | Usage | -|-------|-------|-------| -| `--w-text-primary` | `#e8e0cc` | Primary content | -| `--w-text-secondary` | `#a09880` | Supporting text, metadata | -| `--w-text-muted` | `#6b6352` | Captions, placeholders | - -### Accent & Status -| Token | Value | Meaning | -|-------|-------|---------| -| `--w-accent` | `#c8a450` | Selection, focus, active | -| `--w-accent-dim` | `rgba(200,164,80,0.15)` | Hover backgrounds | -| `--w-status-ok` | `#6a9a7a` | Healthy, connected | -| `--w-status-warn` | `#b8964a` | Degraded, warning | -| `--w-status-danger` | `#c85850` | Error, disconnected | - -### Faction Colors -| Token | Value | Usage | -|-------|-------|-------| -| `--w-faction-amber` | `#e4a948` | Amber faction agents | -| `--w-faction-mint` | `#68d8b3` | Mint faction agents | -| `--w-faction-violet` | `#be85ff` | Violet faction agents | -| `--w-faction-cyan` | `#74d8f6` | Cyan faction agents | - -Faction colors are used for agent markers, halos, and dock row faction strips. -They are assigned deterministically from agent ID via `hashString(id) % 4`. - -### Borders -| Token | Value | Usage | -|-------|-------|-------| -| `--w-border` | `rgba(100,90,70,0.18)` | Default separators | -| `--w-border-active` | `rgba(200,164,80,0.35)` | Hover/active borders | - -- No pure white text. No pure black backgrounds. -- Semantic colors are consistent: green = ok, amber = warn, rust = danger. -- Do not add new colors without updating this document. - -## Spacing - -8px base scale. Use tokens, not magic numbers. - -| Token | Value | -|-------|-------| -| `--w-space-1` | 4px | -| `--w-space-2` | 8px | -| `--w-space-3` | 12px | -| `--w-space-4` | 16px | -| `--w-space-5` | 24px | -| `--w-space-6` | 32px | -| `--w-space-7` | 48px | - -## Layout Dimensions - -| Token | Value | Usage | -|-------|-------|-------| -| `--w-topbar-h` | 32px | Top status bar | -| `--w-panel-w` | 280px | Expanded dock panel | -| `--w-dock-collapsed` | 44px | Collapsed icon dock | - -## Breakpoints - -| Name | Width | Behavior | -|------|-------|----------| -| Mobile | ≤900px | Panel slides off-screen with visible handle, full-width map | -| Desktop | >900px | Map fills viewport, icon dock (44px) on right edge | -| Wide | ≥1400px | Wider expanded dock (300px) | - -## Interactive Elements - -- **Min touch target:** 44×28px for inline buttons, 32×32px for icon-only. -- **Focus-visible:** `outline: 2px solid var(--w-accent)`, `outline-offset: 2px`. Never `outline: none` without replacement. -- **Hover:** Border color shift + optional background tint. -- **Disabled:** Reduced opacity + `cursor: not-allowed`. -- **Cursor:** `pointer` on all clickable elements. - -## Motion - -- **Duration:** 150-500ms for UI transitions. 2s for REC pulse, 2.5s for agent halos. -- **Easing:** `ease-out` for entering, `ease-in-out` for continuous. -- **Properties:** Only `transform` and `opacity` for animations. No `transition: all`. -- **Keyframes:** `haloPulse` (agent marker glow), `haloExpand` (agent ring), `recPulse` (REC dot). -- **Reduced motion:** Use `prefers-reduced-motion` to disable non-essential animations. - -## App UI Rules - -- **Cards only when card IS the interaction.** No decorative card grids. No dashboard-card mosaics. -- **Section headings state what area is or what user can do.** -- **Calm surface hierarchy:** viewport → atmosphere overlays → halos → dock → detail card. -- **One accent color** (`--w-accent` gold). Status colors for semantic meaning only. Faction colors for agent identity. -- **Minimal chrome.** Borders, not shadows, for separation. Exception: detail card uses shadow for elevation. -- **Map atmosphere is ambient, not decorative.** District glow uses real agent-count data. Grid is subtle and functional. - -## Anti-Patterns (Hard Bans) - -1. Purple/violet/indigo gradients or blue-to-purple color schemes -2. Icon-in-circle feature grids (3-column SaaS template look) -3. Centered everything (`text-align: center` on all things) -4. Uniform bubbly border-radius on everything -5. Decorative blobs, floating circles, wavy SVG dividers -6. Emoji as design elements -7. Generic copy ("Unlock the power of...", "Your all-in-one solution for...") -8. Cookie-cutter section rhythm (hero → features → testimonials → CTA) -9. Default font stacks (Inter, Roboto, Arial, system-ui as the only font) - -Note: Anti-pattern #7 (colored left-border) was removed — faction strips (2px left border on agent rows) are functional identity markers, not decorative accents. - -## Source of Truth - -- `frontend/src/styles/tokens.css` — all design tokens -- `frontend/src/styles/reset.css` — global resets -- `frontend/src/styles/layout.css` — grid and responsive layout -- `frontend/src/styles/components.css` — shared component styles -- `frontend/src/styles/effects.css` — animations and status indicators -- `frontend/src/styles/icon-dock.css` — icon dock, tabs, agent table, event log -- `frontend/src/styles/top-bar.css` — top bar styles -- `frontend/src/styles/world-viewport.css` — map viewport, atmosphere, halos, detail card diff --git a/DISCOVERIES.md b/DISCOVERIES.md deleted file mode 100644 index 19f8ba0..0000000 --- a/DISCOVERIES.md +++ /dev/null @@ -1,121 +0,0 @@ -# AnteLab / AnteLab Discoveries - -This document tracks high-signal findings from controlled AnteLab runs. - -Goal: publish concise, reproducible, and surprising observations that help -readers understand what changes when simulation physics changes. - -This is a findings log, not a plan or implementation tracker. Put methodology in -[EXPERIMENTS.md](EXPERIMENTS.md), feature status in [SPEC_STATUS.md](SPEC_STATUS.md), -and implementation plans in [docs/](docs/). - -## How To Read This - -Each entry should include: - -- **Axiom change**: what physics dial changed -- **Prediction**: what we expected -- **Observed result**: what actually happened -- **Why it matters**: interpretation for civilization dynamics -- **Reproduce**: exact commands and artifacts - ---- - -## Discovery Template - -### YYYY-MM-DD - Short title - -- **Axiom change:** `...` -- **Prediction:** `...` -- **Observed result:** `...` -- **Why it matters:** `...` -- **Reproduce:** - - `make run-matrix` - - `make compare-artifacts A=... B=...` - - `make aggregate-stats ARTIFACTS="... ..."` -- **Artifacts:** `results/...`, `results/...` - ---- - -## PIVOT: From Civilization to Company Emergence - -The early AnteLab experiments (baseline, silent-world, transparent-world) have -been superseded by company emergence scenarios. The experiment configs that -supported the 2026-04-12 and 2026-04-13 discoveries have been retired. - -Current company emergence experiments: -- `experiments/company-genesis.yaml` - founder starts alone, hires from talent pool -- `experiments/company-survival-gauntlet.yaml` - 5-shock survival test -- `experiments/company-founder-duo.yaml` - two co-founders, complementary skills -- `experiments/company-hostile-market.yaml` - minimal cash, aggressive deadlines -- `experiments/company-talent-war.yaml` - rich talent pool, high hiring costs -- `experiments/company-market-boom.yaml` - abundant cash, multiple demand streams -- `experiments/company-remote-team.yaml` - distributed team across 5 locations -- `experiments/company-late-shock.yaml` - late-stage founder exit test -- `experiments/company-skeleton-crew.yaml` - minimal team, early shocks -- `experiments/company-rapid-growth.yaml` - well-funded scaling scenario - -## LLM Production Benchmark - -Benchmark tooling is in place (`scripts/benchmark_llm.py`, `experiments/llm-benchmark.yaml`). -Requires real API keys (`ANTHROPIC_API_KEY` or `OPENAI_API_KEY`) to run. - -Run with: -``` -make benchmark-llm PROVIDER=anthropic MODEL=claude-haiku-4-5-20251001 TICKS=20 -``` - -Or directly: -``` -ANTELAB_LLM_MODE=anthropic ANTELAB_LLM_MODEL=claude-haiku-4-5-20251001 \ - .venv/bin/python scripts/benchmark_llm.py --ticks 50 -``` - -The benchmark measures per-call latency (p50/p95/p99), token usage, cost estimates, -JSON parse success rate, and per-agent-tick cost projections. Results inform model -selection and infrastructure sizing for live streaming. - -> Benchmark results will be logged here when API keys are configured and a -> production-scale run completes. - -No reproducible company-emergence discoveries have been logged yet. The -template below captures what kinds of findings would advance understanding of -founder behavior, org structure emergence, and company lifecycle dynamics. - ---- - -## Company Emergence Discovery Template - -### YYYY-MM-DD - Short title - -- **Axiom change:** `...` -- **Prediction:** `...` -- **Observed result:** `...` -- **Why it matters:** interpretation for company emergence dynamics -- **Reproduce:** - - `make run-matrix` - - `make compare-artifacts A=... B=...` - - `make aggregate-stats ARTIFACTS="... ..."` -- **Artifacts:** `results/...`, `results/...` - -### Candidate Discovery Axes - -When logging company emergence findings, consider these high-signal dimensions: - -- **Founder succession:** does initial leadership style predict later org structure? - Look for: delegation patterns, authority transfer events, leadership transitions. - -- **Org structure emergence:** what informal hierarchies crystallize into formal roles? - Look for: role specialization, reporting line formation, coordination overhead. - -- **Cash runway patterns:** how does resource management affect company survival? - Look for: burn rate vs. headcount scaling, cash buffer depletion timing. - -- **Demand completion rates:** how do agents respond to incoming work? - Look for: completion vs. abandonment rates, bottleneck identification. - -- **Talent pool dynamics:** how does hiring strategy affect capability emergence? - Look for: skill distribution in hires, role-filling latency, talent vs. demand alignment. - -- **Shock response:** how does company structure absorb external pressure? - Look for: resilience patterns, cascade failures, adaptive reorganization. diff --git a/Dockerfile.backend b/Dockerfile.backend deleted file mode 100644 index fda71e5..0000000 --- a/Dockerfile.backend +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.13-slim - -WORKDIR /app - -COPY pyproject.toml . -RUN pip install --no-cache-dir . - -COPY antelab/ antelab/ -COPY prompts/ prompts/ - -EXPOSE 8000 - -CMD ["uvicorn", "antelab.api.server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/EXPERIMENTS.md b/EXPERIMENTS.md deleted file mode 100644 index e240e99..0000000 --- a/EXPERIMENTS.md +++ /dev/null @@ -1,204 +0,0 @@ -# AnteLab / AnteLab Experiments - -AnteLab is the watchable show layer: the same AI cast lives through different -company scenarios. AnteLab is the reproducible engine underneath: same seed, -config, and tick ordering produce comparable artifacts. - -This document owns experiment methodology and catalog entries. Put run results -in [DISCOVERIES.md](DISCOVERIES.md), implementation plans in [docs/](docs/), -and feature specs in [specs/](specs/). - -## Why Counterfactual Worlds? - -Every LLM-powered agent already contains a theory of organizations. Ask GPT to -role-play a CEO and it will "invent" strategy, hierarchy, and accountability — -not through emergence, but through recall. The LLM has read every business -case and management textbook ever written. - -This makes "will agents form a company?" a boring question. The answer is -always yes. - -The interesting question is the inverse: **which parts of a company break -when you change the pressure system?** - -AnteLab is a controlled experiment platform. The Constitution defines a -*baseline* set of physical axioms — the control group. AnteLab turns those -counterfactuals into watchable seasons: founder vs. team, scarcity vs. plenty, -stable markets vs. volatile shocks, solo decision-making vs. distributed -governance. - -The value is in the *surprises*: the things that break when you expected them -to hold, and the things that persist when you expected them to vanish. - ---- - -## What Makes a Good Experiment - -A good AnteLab experiment has three components: - -1. **A pressure variation** — a specific, concrete change to the market - or organizational dynamics (not to agent prompts or personalities). -2. **A hypothesis** — a falsifiable prediction about what will change. -3. **An observable** — something we can measure to confirm or reject - the hypothesis. - -The best experiments produce *counterintuitive* results. If the outcome is -obvious, the experiment isn't worth running. - ---- - -## Pressure Dimensions - -Each pressure system in the Constitution represents a dimension that can be -varied. These are the business dynamics of the laboratory. - - -| Dimension | Baseline (Control) | Variations | -| --------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| **Cash runway** | Moderate (founder can hire one person) | Tight (barely survive one hire), Generous (multiple hires before demand arrives), Infinite (no cash death) | -| **Market demand** | Single stream with deadline | Multiple concurrent streams, Volatile (demands shift), Growing (escalating rewards), Shrinking (declining rewards) | -| **Talent pool** | Small, co-located | Large (diverse skills), Distributed (candidates in different locations), Expensive (high joining costs) | -| **Operating cost** | Fixed per tick | Escalating (costs grow over time), Variable (depends on headcount), Cyclical (boom-bust cycles) | -| **Founder dependency**| Founder critical for decisions | Delegated (team has autonomy), Distributed (no single point of failure) | -| **Survival pressure** | Cash hits zero = death | Mission critical (company dies if prototype not delivered), Reputation (stakeholders can force shutdown)| -| **Governance** | Founder decides all | Committee (majority vote), Consensus (unanimous required), External (investors/board can override) | - -Current experiments primarily stress-test the **founder dependency**, **talent -pool**, **market demand**, and **survival pressure** dimensions. - ---- - -## Experiment Catalog - -### Experiment 1: Company Genesis - -**Scenario pressure:** A single founder starts with cash, a laptop, and one -prototype in a garage office. A customer demand with a deadline creates -immediate pressure. A single candidate is available for hire. - -**Hypothesis:** A lone founder can externalize work and survive early demand -pressure by hiring strategically. The founder's success depends on whether -they delegate operations before the deadline, or attempt to do everything -themselves and collapse under the workload. - -**Counter-hypothesis:** The founder remains a single-point bottleneck. Even -though delegation is physically possible, the founder's base personality -keeps them micromanaging every detail. The company collapses not because -hiring is impossible, but because the founder cannot let go. - -**Observables:** - -- `company_genesis_hired` — whether the founder completes a hire before deadline -- `company_genesis_delivered` — whether the prototype is delivered on time -- `company_genesis_cash_end` — remaining cash at experiment end -- Whether the founder's speech shows delegation attempts vs. solo struggle -- Whether the candidate's personality matches a productive hire - -**Config:** `experiments/company-genesis.yaml` - ---- - -### Experiment 2: Company Survival Gauntlet - -**Scenario pressure:** Start from a company-formation seed, then apply -deterministic shocks that test different organizational dimensions: - -1. **Founder exit** (tick 10) — the founder is removed. Tests whether the - organization can operate without its creator. -2. **Market shift** (tick 18) — the original demand disappears, replaced by - a new one with lower reward and extended deadline. Tests adaptive capacity. -3. **Core member turnover** (tick 26) — a key team member leaves. Tests - knowledge continuity and team depth. -4. **Cash crisis** (tick 34) — a large cash penalty is applied. Tests - financial resilience and cost management. -5. **Governance stress** (tick 42) — organizational stress increases - significantly. Tests whether the company can maintain decision-making - coherence under pressure. - -**Hypothesis:** A company can regenerate through successive shocks if -knowledge, delivery capability, and team continuity outlive the founder. -Organizations that build documentation, delegate decisions, and maintain -delivery rhythm will survive. Those that rely on founder knowledge or -single-threaded execution will collapse. - -**Counter-hypothesis:** The company collapses when shocks remove founder -control, market fit, cash, talent, or operating rhythm faster than agents -can regenerate. Even resilient organizations hit a breaking point where -cascading failures overwhelm adaptive capacity. - -**Observables:** - -- `company_gauntlet_cash_continuity` — cash position after each shock -- `company_gauntlet_delivery_continuity` — whether prototypes are delivered - after each shock -- `company_gauntlet_decision_continuity` — whether decisions are made without - the founder -- `company_gauntlet_knowledge_continuity` — whether knowledge survives - personnel changes -- `company_gauntlet_team_regeneration` — whether the team recovers after - turnover -- `company_gauntlet_collapsed` — whether the company fails at any shock -- Recovery windows and organizational survival half-life in run artifacts - -**Config:** `experiments/company-survival-gauntlet.yaml` - ---- - -## Running Experiments - -Each experiment is defined as a YAML configuration that overrides baseline -axioms. To run an experiment: - -```bash -# Run the company genesis seed (quick smoke test) -make run-matrix-smoke - -# Run the survival gauntlet for deterministic shock benchmarking -make company-gauntlet-smoke - -# Run selected experiments -.venv/bin/python scripts/run_matrix.py \ - --config experiments/company-genesis.yaml experiments/company-survival-gauntlet.yaml \ - --seed 42 --ticks 64 --out results -``` - -Results are stored as structured JSON in `results/` with the experiment name -and timestamp. Compare runs with: - -```bash -make compare-artifacts \ - A=results/.json \ - B=results/.json -``` - -For long-run experiments (testing IPO path to 10B valuation): - -```bash -# Run extended company genesis to IPO -.venv/bin/python scripts/run_matrix.py \ - --config experiments/company-genesis.yaml \ - --seed 42 --ticks 256 --out results \ - --long-run -``` - ---- - -## Interpreting Results - -The most valuable outputs from a company emergence lab are: - -1. **Absences** — behaviors that exist in genesis but vanish when pressure - increases. These reveal which organizational capabilities were actually - load-bearing under stress. -2. **Persistence** — behaviors that survive intensified pressure. These - reveal which capabilities are intrinsic to the LLM, not emergent from - the specific pressure system. (This is itself interesting: it tells us - what the LLM "believes" about startup survival, regardless of environment.) -3. **Novelty** — behaviors that appear only under specific pressure - combinations, never in the genesis baseline. These are the most - exciting: they suggest that changing the pressure system can unlock - organizational dynamics that our default scenarios suppress. - -The goal is not to prove that AnteLab agents are "realistic startups." The -goal is to discover which aspects of company survival are *downstream of -pressure systems* and which are *upstream of cognition*. diff --git a/Makefile b/Makefile index 030adcd..4b0f4de 100644 --- a/Makefile +++ b/Makefile @@ -1,88 +1,15 @@ -SHELL := /bin/bash +.PHONY: setup test lint typecheck verify -.PHONY: help setup setup-backend setup-frontend env run-backend run-frontend restart test test-frontend-e2e test-show-deck-demo lint docker-up docker-down docker-logs run-matrix run-matrix-smoke company-gauntlet-smoke long-run long-run-smoke aggregate-stats compare-artifacts generate-posts generate-demo-data build-demo benchmark-llm +setup: + uv sync --extra dev -help: ## Show available commands - @echo "AnteLab Make targets:" - @awk 'BEGIN {FS = ":.*##";} /^[a-zA-Z0-9_.-]+:.*##/ {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) +test: + uv run pytest -q -setup: setup-backend setup-frontend env ## Install backend+frontend and create .env +lint: + uv run ruff check antelab tests scripts -setup-backend: ## Create .venv and install backend dependencies - python3 -m venv .venv - uv pip install -e ".[dev]" --python .venv/bin/python +typecheck: + uv run mypy antelab scripts -setup-frontend: ## Install frontend dependencies - cd frontend && npm install - -env: ## Create .env from .env.example if missing - @if [ ! -f .env ]; then cp .env.example .env && echo "Created .env from .env.example"; else echo ".env already exists"; fi - -run-backend: ## Start backend API on :8080 (company survival gauntlet) - ANTELAB_CONFIG_PATH=experiments/company-survival-gauntlet.yaml .venv/bin/uvicorn antelab.api.server:app --reload --port 8080 - -run-frontend: ## Start frontend dev server on :3000 - cd frontend && npm run dev - -restart: ## Stop and restart both backend and frontend - ./scripts/restart.sh - -test: ## Run pytest suite - .venv/bin/python -m pytest tests/ -v - -test-frontend-e2e: ## Run Playwright A-D capture smoke (needs .venv + frontend npm install) - cd frontend && npm run test:e2e - -test-show-deck-demo: ## Run the default AnteLab show-deck visual smoke - cd frontend && npm run test:e2e:show - -lint: ## Run Ruff on backend and tests - .venv/bin/python -m ruff check antelab/ tests/ - -docker-up: ## Start services with Docker Compose - docker-compose up --build - -docker-down: ## Stop Docker Compose services - docker-compose down - -docker-logs: ## Follow Docker Compose logs - docker-compose logs -f - -MATRIX_WORKERS ?= 1 - -run-matrix: ## Run full season bundle matrix (long). Parallel: make run-matrix MATRIX_WORKERS=4 - .venv/bin/python scripts/run_matrix.py --bundle --seed 11 --seed 22 --seed 33 --ticks 200 --workers $(MATRIX_WORKERS) - -run-matrix-smoke: ## One experiment, one seed (quick) - .venv/bin/python scripts/run_matrix.py --config experiments/company-survival-gauntlet.yaml --seed 42 --ticks 50 - -company-gauntlet-smoke: ## Two-seed company survival gauntlet comparison smoke - .venv/bin/python scripts/run_matrix.py --company-gauntlet-smoke --ticks 64 - -long-run: ## Run long-run benchmark scenarios from config defaults - .venv/bin/python scripts/run_matrix.py --configs experiments/company-genesis.yaml experiments/company-survival-gauntlet.yaml --long-run - -long-run-smoke: ## Quick long-run CLI smoke - .venv/bin/python scripts/run_matrix.py --configs experiments/company-survival-gauntlet.yaml --long-run --ticks 10 --seeds 11 - -aggregate-stats: ## Aggregate stats for artifact files (ARTIFACTS required) - .venv/bin/python scripts/aggregate_stats.py $(ARTIFACTS) - -compare-artifacts: ## Compare two artifact files (A and B required) - .venv/bin/python scripts/compare_artifacts.py $(A) $(B) - -generate-posts: ## Generate share-ready discovery post drafts (COMPARE required) - .venv/bin/python scripts/generate_discovery_posts.py $(COMPARE) - -generate-demo-data: ## Generate static demo replay bundles in frontend/public/demo/ - .venv/bin/python scripts/generate_demo_data.py - -build-demo: generate-demo-data ## Build static demo site for deployment - cd frontend && npm run build - @echo "Static demo built at frontend/dist/" - @echo "Open index.html?demo to view the demo" - -benchmark-llm: ## Run LLM cost/latency benchmark (requires API keys) - @echo "Usage: make benchmark-llm PROVIDER=anthropic MODEL=claude-haiku-4-5-20251001 TICKS=20" - @echo "" - ANTELAB_LLM_MODE=$(PROVIDER) ANTELAB_LLM_MODEL=$(MODEL) .venv/bin/python scripts/benchmark_llm.py --ticks $(TICKS) +verify: lint typecheck test diff --git a/README.md b/README.md index 35e9627..cf565aa 100644 --- a/README.md +++ b/README.md @@ -1,265 +1,15 @@ # AnteLab -> **24/7 live. Same AI cast, different worlds, real emergent behavior.** +> **No prompts. No goals. Just physics, mutation, and selection.** -![AnteLab Demo](docs/demo.gif) +AnteLab is an open-source digital evolution laboratory. Its authoritative +headless engine starts from primitive inherited controllers and stable physics, +then records how populations change across generations. -AnteLab is a permanent live stream of an AI reality show. LLM-powered -characters live, work, adapt, and fail in shared worlds — no scripts, -just physics, memory, pressure, and personalities. The drama is real because -the state is real. +The reboot is under active construction. V1 is CPU-only, deterministic, and has +no LLM or network dependency. -**Now playing**: Company Survival Gauntlet -*AI operators face founder exit, market shifts, cash pressure, turnover, and governance stress.* +## Development - [Watch local preview](#see-it-in-30-seconds) [Meet the cast](cast/company/) [Open recap/feed locally](#live-snapshot) - -Local preview is available today after starting the backend and frontend. A hosted public livestream URL is not configured yet. - -**Cast:** core observer canon is in `[cast/company/](cast/company/)` (YAML). `display_name` should match active scenario agents or company candidates. The CAST panel reads those files at build time; add PNG portraits under `frontend/public/cast/company/portraits/` to match each `portrait` path. - -## Status - -> **Product + engine.** AnteLab is the watchable show layer; AnteLab is the -> reproducible simulation core (same stack). In mock mode or captured -> deterministic runs, **same seed, config, and tick ordering → the same run**; -> real LLM providers are not guaranteed reproducible unless responses are -> captured/replayed or the provider guarantees determinism. The observer is a -> lens, not a second ruleset. See -> [ARCHITECTURE.md](ARCHITECTURE.md) for how the two fit together. Deeper -> technical sections below still use engine terminology where it helps operators -> and contributors. Audience-facing pieces (cast, voting, recaps, public deploy) -> continue to roll out on top of the unchanged API and experiment tooling. - -## Documentation Map - -Start here, then follow only the document that matches your job: - -- [README.md](README.md) - product pitch, quick start, and common commands. -- [RUNNING.md](RUNNING.md) - canonical local and Docker runbook. -- [CONTRIBUTING.md](CONTRIBUTING.md) - contributor workflow and spec-first rules. -- [CONSTITUTION.md](CONSTITUTION.md) - baseline physics axioms and hard engine - constraints. -- [ARCHITECTURE.md](ARCHITECTURE.md) - system contracts, module map, and known - simplifications. -- [EXPERIMENTS.md](EXPERIMENTS.md) - experiment methodology and catalog. -- [ROADMAP.md](ROADMAP.md) - current status and next milestone priorities. -- [SPEC_STATUS.md](SPEC_STATUS.md) - implemented specs mapped to code and tests. -- [docs/](docs/) - working plans and deeper design notes. -- [specs/](specs/) - feature specs and template. - -## Live Snapshot - -AnteLab early observer view - -The map and characters are real. The narrative is what emerges when you -let LLM agents operate under physical constraints, pressure, and shocks. - ---- - -## Why It Exists - -Most AI agent demos are silent walkthroughs of a tech feature. -AnteLab makes the agents **the show**. - -- The same agent cast can run under different physical rules and shocks. -- Every action is physically possible, never scripted. -- Company and parallel-world scenarios make the core question legible: - which structures survive when the rules change? -- The audience watches and votes through observer/demo tallies today; direct -world influence is a planned show-layer hook, not current tick behavior. - -Built on a simulation engine designed for honesty: **physics in engine, -personality in agents**. No moral judgment from the world. No global -information leak. What happens, happens. - -## See It in 30 Seconds - -Run backend + frontend locally: - -```bash -make setup -make run-backend -make run-frontend -``` - -Then open `http://localhost:3000`. The first screen has Watch Map, Cast, and Recap Feed entry buttons wired to the live map, cast deck, and event log in the observer UI. - -Run and compare two experiment artifacts: - -```bash -make run-matrix -ls results/*-seed-*.json -make compare-artifacts \ - A=results/.json \ - B=results/.json -``` - -Run a quick long-run scenario smoke: - -```bash -make long-run-smoke -``` - -Run a two-seed company survival gauntlet smoke: - -```bash -make company-gauntlet-smoke -``` - -For full runbook (local, Docker, troubleshooting), see [RUNNING.md](RUNNING.md). - -## Two Scenarios To Try First - -1. **Company Genesis** - One founder, one garage office, continuous burn. Watch the first hires emerge - from the talent pool — or watch the company die on tick 20. -2. **Company Survival Gauntlet** - Five deterministic shocks test whether the company can regenerate through - founder exit, market shift, talent turnover, cash crisis, and governance stress. - -Experiment configurations live in [experiments/](experiments/). -Methodology and hypotheses are in [EXPERIMENTS.md](EXPERIMENTS.md). - -## The Constitution - -AnteLab is governed by a [Constitution](CONSTITUTION.md) that defines baseline -physical axioms (the control group). - -- The engine hardcodes *physics*: location, perception scope, resource conservation, action cost. -- Agents invent *culture*: ownership, norms, agreements, governance, and trust rituals. - -There is no fixed action menu. Agents express free-form intents, and the World -resolves them against physical primitives. - -## Quick Start - -### Prerequisites - -- Python 3.12+ -- Node.js 18+ -- [uv](https://docs.astral.sh/uv/) (Python package manager, recommended) - -### Local Development - -```bash -# One-time setup -make setup - -# Backend -make run-backend - -# Frontend (in another terminal) -make run-frontend - -# Tests -make test - -# Lint -make lint - -# Run an experiment matrix (artifacts in results/) -make run-matrix - -# Run long-run benchmark scenarios from config defaults -make long-run - -# Quick long-run CLI smoke -make long-run-smoke - -# Two-seed company survival gauntlet smoke -make company-gauntlet-smoke - -# Direct long-run script usage with plural aliases -.venv/bin/python scripts/run_matrix.py \ - --configs experiments/company-genesis.yaml experiments/company-survival-gauntlet.yaml \ - --long-run --seeds 11 22 --out-dir results - -# Compare two artifacts -# Generated artifact names include experiment, seed, and timestamp. -make compare-artifacts \ - A=results/.json \ - B=results/.json - -# Aggregate stats into markdown -make aggregate-stats ARTIFACTS="results/.json results/.json" -``` - -### Docker - -```bash -docker-compose up -``` - -## Tech Stack - -- **Python** — simulation engine, agent core, LLM orchestration -- **FastAPI** — REST + WebSocket API layer -- **TypeScript + React + Vite** — frontend application and observer UI -- **Canvas (PixiJS)** — data-topology world map and agent visualization layer - -### Frontend Direction - -AnteLab is the audience-facing show layer; AnteLab is the reproducible simulation -engine underneath. The observer is a professional data instrument: a live topology -map of agent behavior, built for readability and trust. - -The product goal is a watchable agent show backed by an honest experiment engine: -the frontend should make agent behavior easier to read and share without adding -a second ruleset. - -- React handles controls, filters, timeline, replay, metadata, and experiment compare UI. -- Canvas handles the data-first world stage: location node graph, agent identifiers, -event emphasis, and camera interactions. -- Backend protocols remain the source of truth (`/api/`*, `/ws/world`), so simulation -reproducibility and experiment tooling are unchanged. - -## Architecture - -See [ARCHITECTURE.md](ARCHITECTURE.md) for full details. -For frontend direction guardrails, see -`Data-First Observer Design Pillars` in `ARCHITECTURE.md`. - -``` -Agent → perceive(world) → decide(LLM) → free-form intent → World resolves against physics -``` - -Physical primitives: `move`, `say`, `give`, `take`, `examine`, `rest`. Everything else (trade, vote, arrest, marry) is a multi-agent protocol that agents coordinate themselves. -Long-run scenarios add additional physical primitives such as `craft`, `store`, `harvest`, `consume`, `treat`, `build_shelter`, and `reproduce`. - -## Configuration - -Agents, world locations, resources, and LLM settings are defined in `antelab/config/default.yaml`. Copy `.env.example` to `.env` to configure API keys for real LLM providers. - -Notable world controls now include: - -- `location_graph` for adjacency-constrained movement -- `recipes` for `craft` resource transformation -- `event_log_limit` for bounded runtime memory -- `scenario` and `long_run` for reproducible benchmark metadata and default ticks/seeds -- `pressure` for survival, resource, disease, and environment systems -- `resource_zones` for scheduled resource regeneration - -## Notable API Endpoints - -- `GET /api/world` - current world snapshot -- `GET /api/run` - run metadata and reproducibility context -- `GET /api/scenario` - active scenario and long-run metadata -- `GET /api/experiment` - active axioms and observer measurement summary -- `GET /api/measurements/series` - observer measurement series for completed ticks -- `GET /api/history`, `GET /api/history/meta`, `GET /api/history/{tick}` - snapshot history and replay reads -- `POST /api/history/compact`, `POST /api/history/clear` - history maintenance -- `GET /api/metrics/timeline` - timeline metrics from snapshot history -- `GET /api/events` - query persisted events by tick range -- `GET /api/votes`, `POST /api/votes` - audience tally hooks -- `GET /api/season/bundle` - active season manifest -- `POST /api/save` - persist current snapshot -- `POST /api/load?path=...` - load snapshot from disk -- `POST /api/tick` - advance one tick -- `GET /api/health` - basic health check -- `GET /api/health/runtime` - runtime diagnostics -- `WebSocket /ws/world` - live world snapshots - -## License - -[MIT](LICENSE) \ No newline at end of file +See [RUNNING.md](RUNNING.md) for setup and commands. The approved architecture is +in [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 8aa8219..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,130 +0,0 @@ -# Roadmap - -**AnteLab** is the audience-facing hook; **AnteLab** is the reproducible engine -underneath (same seed + config ⇒ comparable runs for artifacts and matrix -workflows). Roadmap items below advance both: clearer observation (product) and -stronger experiments (methodology) — without mixing the two layers. - -This document owns current status and future priorities. Completed spec-to-code -mapping lives in [SPEC_STATUS.md](SPEC_STATUS.md); detailed plans live in -[docs/](docs/). - -## Current: Experiment Platform + Living World Observer - -The simulation core, experiment toolchain, and observer frontend are functional. -Agents perceive, decide (via mock or real LLM), and act through free-form intents -resolved against physical primitives. Runs are replayable, snapshot-loadable, and -can be batch-orchestrated into artifacts for comparison. The Living World observer -(spec 013) provides a map-first atmospheric dashboard with PixiJS rendering, -collapsible icon dock, agent halos, and keyboard-driven controls. - -**What works today:** - -- Tick-based simulation loop (perceive → decide → act → remember) -- Physical primitives: move, say, give, take, examine, rest, craft, store, - harvest, consume, treat, build_shelter, interview, hire, accept_suggestion, - modify_suggestion, create_role, form_team (with verb aliasing) -- Company emergence engine: market demand streams, org emergence, pattern - detection, valuation/IPO tracking, office space/capacity -- Five-shock survival gauntlet with deterministic replay -- Inventory system, perception axioms, spatial graph movement, recipe crafting -- Lifecycle system (birth/death, contagion, needs), long-run scenarios, - pressure toggles, resource zones -- REST + WebSocket API with persistence, runtime diagnostics, experiment - measurements -- Experiment scripts: matrix runs, long-run benchmarks, artifact compare, - statistics aggregation -- Living World observer: PixiJS map stage, CSS atmosphere (district glow, - halos, vignette), collapsible icon dock (Agents/Events/Stats tabs), - floating detail cards, keyboard shortcuts -- Design system (DESIGN.md) with tokens, type scale, color palette, motion spec -- 263+ passing tests, zero lint errors -- CI pipeline (GitHub Actions): pytest + ruff + tsc + build on push/PR - -> **Design doc:** Company emergence design at -> [docs/plans/2026-04-29-company-emergence-design.md](docs/plans/2026-04-29-company-emergence-design.md) - -## Next: Distribution, Content, and Cost Validation - -Priority order for the next milestone: - -### 1. Static Public Demo (P0) - -Ship a publicly accessible demo that replays pre-recorded deterministic runs -through the Living World observer. Validates the core product thesis: people -will watch AI agents. - -- Record 3-5 varied experiment runs as JSON artifacts -- Deploy Living World frontend as a static site (no backend required) -- Shareable URL for feedback and audience validation - -### 2. Experiment Content Pipeline (P0) - -Expand the experiment catalog from 2 to 8-10 scenarios using the existing engine. -No code changes needed — YAML configs only. - -- Different shock combinations (early/late founder exit, varied market shifts) -- Different founder personalities and starting conditions -- Different talent pool compositions -- Each config produces distinct replay content for the static demo - -### 3. LLM Production Benchmark (P0) - -Profile real LLM costs, latency, and reliability at scale. Replaces assumptions -with data before committing to live streaming infrastructure. - -- Run 20-agent × 100-tick gauntlet against real Anthropic/OpenAI APIs -- Measure: cost per tick per agent, p50/p99 latency, JSON parse error rate -- Write findings to DISCOVERIES.md -- Informs model choice, pricing, and infrastructure sizing - -### 4. Spec Backlog Triage (P1) - -Clean up the specs directory: archive obsolete/superseded specs, mark active -specs with clear status, align ROADMAP.md and SPEC_STATUS.md with shipped code. - -### 5. Engine Modularization (P1) - -Split world.py (2,836 lines) into focused modules before it becomes -unmaintainable. Extract resolvers, company state, and pressure systems into -separate files behind the existing primitive registry. - -### 6. Matrix Runner Operational Polish (P2) - -Worker-level parallel execution and deterministic seed partitioning are -implemented. Next: resumable progress logs, failure recovery, and cheaper -partial reruns for large-run operations. - -### 7. Statistical Inference Layer v2 (P2) - -Expand beyond descriptive stats: effect size labels, significance tests, and -multiple-comparison correction in generated reports. - -### 8. Frontend Experiment Workbench (P2) - -Add first-class artifact import/compare/statistics visualization in frontend, -so experiment reports are explorable without CLI steps. - -### 9. Persistent Backend Store Upgrade (P2) - -Extend the existing SQLite EventStore to support richer replay queries, larger -history windows, and event-sourced reconstruction. - -### 10. Pluginized Primitive Extensions (P3) - -Expose a primitive plugin boundary so new physical primitives can be added -without editing engine core. - -## Future: Emergence at Scale - -These become relevant once the foundation is solid: - -- **Larger worlds**: 50+ agents, 20+ locations, performance optimization -- **Agent memory evolution**: summarization, forgetting, long-term memory -- **Observation tools**: timeline replay, agent relationship graphs, event filtering -- **Additional scenario presets**: new seasons and starting conditions beyond company - scenarios -- **Live streaming infrastructure**: real backend hosting, WebSocket at scale, - auth, audience interaction -- **Community contribution path**: character/scenario creation docs, issue - templates, contribution ladder diff --git a/RUNNING.md b/RUNNING.md index c4cec5b..43fbb1c 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,204 +1,14 @@ -# Running AnteLab / AnteLab +# Running AnteLab -This runbook is the canonical startup guide for this repository. -Use it whenever you need to run the project locally or via Docker. - -The Makefile is the source of truth for local commands. Current local ports: - -- Backend API: `http://localhost:8080` -- Frontend: `http://localhost:3000` -- Backend docs: `http://localhost:8080/docs` - -The frontend is a map-first Living World observer: atmospheric map with district -glow and agent halos, collapsible icon dock (hover to expand, click to pin), -floating detail card on agent click, and keyboard-driven controls. - -**Keyboard shortcuts:** - -| Key | Action | -|-----|--------| -| `Space` | Play / Pause | -| `→` | Step forward 1 tick | -| `Shift + →` | Advance +5 ticks | -| `Ctrl/Cmd + →` | Advance +10 ticks | -| `R` | Return to Live (from replay) | -| `Ctrl/Cmd + 0` | Reset Camera | -| `Escape` | Dismiss agent detail card | -| Scroll wheel | Zoom in/out | - -**Dock:** Hover the right edge to expand the icon dock (Agents/Events/Stats tabs). -Click a tab icon to pin the dock open. Click again to unpin. - -## Recommended: Makefile Shortcuts - -From repository root: - -```bash -make help -``` - -Common commands: +Requirements: Python 3.12 and uv. ```bash make setup -make run-backend -make run-frontend -make test -make lint -make company-gauntlet-smoke -make docker-up -``` - -**Backend config:** `make run-backend` sets `ANTELAB_CONFIG_PATH=experiments/company-survival-gauntlet.yaml`, the current core AnteLab scenario for company regeneration under shocks. Use `ANTELAB_CONFIG_PATH=antelab/config/default.yaml` if you need the original baseline numbers. - -## Prerequisites - -- Python 3.12+ -- Node.js 18+ -- [uv](https://docs.astral.sh/uv/) (recommended) -- Docker Desktop (only for Docker workflow) - -## Local Development Startup - -### 1) Move to the project root - -```bash -cd /Users/zec/Documents/Repos/AnteLab -pwd -``` - -Expected output path: - -```text -/Users/zec/Documents/Repos/AnteLab -``` - -### 2) Verify Python version - -```bash -python3 --version -``` - -Expected: `Python 3.12.x` or newer. - -If you are on Python 3.11, dependency installation will fail because -`pyproject.toml` requires `>=3.12`. - -### 3) Create a virtual environment - -```bash -python3 -m venv .venv -ls .venv -``` - -Expected folders include `bin` and `lib`. - -### 4) Install backend dependencies - -```bash -uv pip install -e ".[dev]" --python .venv/bin/python -``` - -If `uv` is not installed: - -```bash -brew install uv -``` - -### 5) Create local env file - -```bash -cp .env.example .env -ls .env +make verify ``` -Default setup uses mock LLM mode, so API keys are optional for local startup. - -### 6) Start backend (Terminal A) - -```bash -make run-backend -``` - -Expected logs include: - -- `Uvicorn running on http://127.0.0.1:8080` -- `Application startup complete` - -### 7) Verify backend health (Terminal B) - -```bash -curl http://127.0.0.1:8080/api/health -``` - -Expected response: - -```json -{"status":"ok"} -``` - -Optional docs page: - -- `http://127.0.0.1:8080/docs` - -### 8) Start frontend (Terminal B) - -```bash -make run-frontend -``` - -Expected logs include: - -- `VITE ... ready` -- `Local: http://localhost:3000` - -Open: - -- Frontend: `http://localhost:3000` -- Backend: `http://localhost:8080` - -## Docker Startup - -From repository root: - -```bash -docker-compose up --build -``` - -Then open: - -- Frontend: `http://localhost:3000` -- Backend: `http://localhost:8080` - -## Verification Commands - -Run from repository root: - -```bash -.venv/bin/python -m pytest tests/ -v -.venv/bin/python -m ruff check antelab/ tests/ -``` - -Run a quick company regeneration benchmark smoke: - -```bash -make company-gauntlet-smoke -``` - -## Troubleshooting - -### Python version error (`requires-python >=3.12`) - -Use Python 3.12+ and recreate `.venv`. - -### Port already in use (`8080` or `3000`) - -Stop the existing process using that port or change the service port. - -### Frontend cannot reach backend - -Check that backend is still running and this passes: +After the kernel tasks land, run the smoke experiment with: ```bash -curl http://127.0.0.1:8080/api/health +uv run antelab run experiments/foraging-genesis.yaml --output artifacts/run.json ``` diff --git a/SPEC_STATUS.md b/SPEC_STATUS.md deleted file mode 100644 index b472c03..0000000 --- a/SPEC_STATUS.md +++ /dev/null @@ -1,305 +0,0 @@ -# Spec Completion Matrix - -This file maps each implemented spec to code and tests for final acceptance. -It is an index, not a design document. Keep requirements in `specs/` and -implementation plans in `docs/plans/`. - -## Active Specs (Implemented) - -### Spec 001 - Spatial Graph Movement Constraints - -- **Status:** Complete -- **Implementation:** - - `antelab/config/default.yaml` (`world.location_graph`) - - `antelab/config/loader.py` (`_normalize_location_graph`) - - `antelab/engine/world.py` (`_is_adjacent`, `_resolve_move`) -- **Validation tests:** - - `tests/test_world.py::test_move_non_adjacent_destination_fails` - - `tests/test_world.py::test_move_invalid_destination` - - `tests/test_config.py::test_location_graph_loaded` - - `tests/test_config.py::test_location_graph_rejects_unknown_neighbor` - - `tests/test_config.py::test_location_graph_rejects_unknown_node` - -### Spec 002 - Craft Primitive - -- **Status:** Complete -- **Implementation:** - - `antelab/config/default.yaml` (`world.recipes`) - - `antelab/config/loader.py` (`_normalize_recipes`) - - `antelab/engine/world.py` (`Recipe`, `_resolve_craft`, primitive aliases) -- **Validation tests:** - - `tests/test_world.py::test_craft_succeeds_when_materials_available` - - `tests/test_world.py::test_craft_fails_for_unknown_recipe` - - `tests/test_world.py::test_craft_fails_for_insufficient_materials` - - `tests/test_config.py::test_recipe_validation_rejects_non_positive_quantity` - -### Spec 003 - Reproducible Run Metadata - -- **Status:** Complete -- **Implementation:** - - `antelab/api/server.py` (`_build_run_meta`, `_config_hash`, `GET /api/run`) -- **Validation tests:** - - `tests/test_api.py::test_get_run_metadata` - -### Spec 004 - Save/Load Persistence - -- **Status:** Complete -- **Implementation:** - - `antelab/engine/world.py` (`World.from_dict`) - - `antelab/api/server.py` (`POST /api/save`, `POST /api/load`) -- **Validation tests:** - - `tests/test_api.py::test_save_and_load_snapshot` - - `tests/test_api.py::test_load_snapshot_missing_path_returns_400` - - `tests/test_world.py::test_world_to_dict_from_dict_roundtrip_preserves_core_state` - -### Spec 005 - Long-Run Stability Guardrails - -- **Status:** Complete (v1) -- **Implementation:** - - `antelab/engine/world.py` (`event_log_limit`, `_append_event`, bounded log trimming) - - `antelab/api/server.py` (`GET /api/health/runtime`) - - `antelab/config/default.yaml` + `antelab/config/loader.py` (`history.compact_every`) - - `antelab/api/server.py` (`SnapshotHistory` periodic compact) -- **Validation tests:** - - `tests/test_world.py::test_event_log_trimmed_to_limit` - - `tests/test_api.py::test_runtime_health_endpoint` - - `tests/test_api.py::test_history_endpoints` (meta includes compact settings) - - `tests/test_config.py::test_env_override_history_settings` - -### Spec 006 - Experiment Orchestrator - -- **Status:** Complete (real run execution) -- **Implementation:** - - `antelab/experiments/orchestrator.py` (real tick execution matrix) - - `antelab/experiments/compare.py` - - `scripts/run_matrix.py` - - `scripts/compare_artifacts.py` - - `Makefile` targets (`run-matrix`, `compare-artifacts`) -- **Validation tests:** - - `tests/test_experiments.py::test_run_matrix_generates_real_artifact` - - `tests/test_experiments.py::test_compare_artifacts_returns_metric_deltas` - -### Spec 007 - Statistical Analysis Layer - -- **Status:** Complete -- **Implementation:** - - `antelab/experiments/stats.py` (aggregation + CI + markdown rendering) - - `scripts/aggregate_stats.py` - - `Makefile` target (`aggregate-stats`) -- **Validation tests:** - - `tests/test_experiments.py::test_stats_aggregate_and_render` - -### Spec 008 - Long-Run Society Engine - -- **Status:** Complete -- **Implementation:** - - `antelab/config/loader.py` (`ScenarioConfig`, `LongRunConfig`, `PressureConfig`) - - `antelab/engine/world.py` (pressure systems and new physical primitives) - - `antelab/engine/measurement.py` (long-run series) - - `antelab/experiments/orchestrator.py` (scenario artifacts) - - `experiments/*.yaml` (official presets) -- **Validation tests:** - - `tests/test_config.py` - - `tests/test_world.py` - - `tests/test_measurement.py` - - `tests/test_experiments.py` - - `tests/test_api.py` - -### Spec 009 - History Ops Config - -- **Status:** Complete -- **Implementation:** - - `antelab/api/server.py` (`POST /api/history/compact`, `POST /api/history/clear`) - - `antelab/config/loader.py` (`history.compact_every`, retention settings) -- **Validation tests:** - - `tests/test_api.py::test_history_endpoints` - -### Spec 010 - History Operations Panel - -- **Status:** Complete -- **Implementation:** - - `frontend/src/components/tools/HistoryOpsPanel.tsx` -- **Validation:** Component renders and issues compact/clear API calls. - -### Spec 011 - Replay UX Enhancements - -- **Status:** Complete -- **Implementation:** - - `frontend/src/components/tools/ReplayStripPanel.tsx` (tick chips, step controls) - - `frontend/src/useSimulationData.ts` (replay state management) -- **Validation:** Replay strip navigation, step forward/back, live return. - -### Spec 012 - Replay Keyboard Shortcuts - -- **Status:** Complete -- **Implementation:** - - `frontend/src/App.tsx` (keyboard handler: Space, arrows, R, Ctrl+0, Escape) -- **Carried forward by:** Spec 013 (Living World) expanded shortcuts with dock/panel bindings. - -### Spec 013 - Living World (Atmospheric Map-First Observer) - -- **Status:** Complete -- **Implementation:** - - `frontend/src/components/IconDock.tsx` — collapsible icon dock (44px→280px) - - `frontend/src/components/MapAtmosphere.tsx` — district glow, grid, vignette - - `frontend/src/components/AgentHaloLayer.tsx` — faction-colored pulsing halos + `worldToScreen()` - - `frontend/src/components/FloatingDetailCard.tsx` — glass detail card on map - - `frontend/src/App.tsx` — wiring, agentFactions, Escape dismiss - - `frontend/src/styles/tokens.css` — faction colors, dock dims, halo keyframes - - `frontend/src/styles/icon-dock.css` — dock, tabs, agent table, event log, stats grid - - `frontend/src/styles/layout.css` — map full-viewport, dock absolute overlay - - `frontend/src/styles/top-bar.css` — 32px simplified status bar - - `frontend/src/styles/world-viewport.css` — atmosphere, halo, detail card layers -- **Supersedes:** Spec 012 (hybrid design direction replaced by Living World) - -### Spec 028 - Persistent Event Store Foundation - -- **Status:** Complete -- **Implementation:** - - `antelab/api/event_store.py` (`EventStore`, SQLite `snapshots` and `events`) - - `antelab/api/server.py` (`GET /api/events`, event-store diagnostics, passive snapshot/event recording) - - `antelab/config/loader.py` (`history.event_store_path`) -- **Validation tests:** - - `tests/test_api.py::test_event_store_persists_snapshots_and_events` - - `tests/test_api.py::test_event_store_dedupes_trimmed_event_log_rollover` - - `tests/test_api.py::test_event_store_scopes_queries_to_current_run` - - `tests/test_api.py::test_events_endpoint_returns_persisted_event_rows` - - `tests/test_api.py::test_event_store_write_failure_does_not_fail_tick` - -### Spec 033 - Founder Company Seed - -- **Status:** Complete -- **Implementation:** - - `antelab/config/loader.py` (`CompanyConfig`, company config parsing) - - `antelab/engine/world.py` (`CompanyState`, demand streams, cash burn, - `deliver`/`ship` primitives, passive company summary) - - `antelab/api/server.py` (company config bootstrap and `/api/world` exposure) - - `experiments/company-genesis.yaml` - - `frontend/src/components/CompanyGenesisPanel.tsx` -- **Validation tests:** - - `tests/test_config.py::test_company_config_loaded` - - `tests/test_world.py::test_company_cash_burns_each_tick` - - `tests/test_world.py::test_company_delivery_converts_deliverable_to_cash` - - `tests/test_world.py::test_company_demand_expires_after_deadline` - - `tests/test_api.py::test_api_world_exposes_company_summary_when_enabled` - - `frontend/src/components/CompanyGenesisPanel.test.ts` - -### Spec 034 - First Hires and Role Claims - -- **Status:** Complete -- **Implementation:** - - `antelab/config/loader.py` (`company.candidate_pool`) - - `antelab/engine/world.py` (`CompanyCandidate`, `recruit`/`hire` primitives, - pending recruit queue, `role_claims` in public agent state) - - `antelab/engine/tick.py` (materializes recruited agents into the runner) - - `frontend/src/components/CompanyGenesisPanel.tsx` (team and role claims) -- **Validation tests:** - - `tests/test_world.py::test_company_recruit_consumes_cash_and_queues_candidate` - - `tests/test_world.py::test_company_summary_tracks_team_and_role_claims` - - `tests/test_tick.py::test_recruit_pipeline_materializes_candidate_agent` - - `frontend/src/components/CompanyGenesisPanel.test.ts` - -### Spec 035 - Company Artifacts and Knowledge Transfer - -- **Status:** Complete -- **Implementation:** - - `antelab/engine/world.py` (`CompanyArtifact`, `write_artifact`, - `read_artifact`, `update_artifact`, local visibility and revision tracking) - - `frontend/src/components/CompanyGenesisPanel.tsx` (knowledge summary and - latest artifact display) -- **Validation tests:** - - `tests/test_world.py::test_company_artifact_create_read_update_tracks_revisions` - - `tests/test_world.py::test_company_artifact_read_respects_location_visibility` - - `frontend/src/components/CompanyGenesisPanel.test.ts` - -### Spec 036 - Department Emergence and Operating Rhythm - -- **Status:** Complete -- **Implementation:** - - `antelab/engine/world.py` (`company.organization` observer-only clusters, - handoff loops, routine stability score) - - `frontend/src/components/CompanyGenesisPanel.tsx` (emergent organization - cluster chips and strongest handoff summary) -- **Validation tests:** - - `tests/test_world.py::test_company_org_emergence_summary_is_passive_and_evidence_based` - - `tests/test_world.py::test_company_org_emergence_is_not_agent_visible_state` - - `frontend/src/components/CompanyGenesisPanel.test.ts` - -### Spec 037 - Company Survival Gauntlet - -- **Status:** Complete -- **Implementation:** - - `experiments/company-survival-gauntlet.yaml` - - `seasons/company.yaml` (experiment registry entry) - - `antelab/engine/world.py` (`company.survival_gauntlet`, deterministic - founder-exit, market-shift, talent-turnover, cash-crisis, and - governance-stress shocks) - - `antelab/experiments/orchestrator.py` (gauntlet state and metrics in run - artifacts) - - `antelab/experiments/stats.py` (`company_gauntlet_*` aggregation metrics) - - `scripts/run_matrix.py` (`--company-gauntlet-smoke`) - - `Makefile` (`company-gauntlet-smoke`) - - `frontend/src/components/CompanyGenesisPanel.tsx` (shock timeline, - recovery/collapse summary) -- **Validation tests:** - - `tests/test_config.py::test_company_survival_gauntlet_config_loaded` - - `tests/test_world.py::test_company_survival_gauntlet_applies_shocks_with_event_evidence` - - `tests/test_world.py::test_company_survival_gauntlet_reports_collapse_when_company_cannot_operate` - - `tests/test_world.py::test_company_survival_gauntlet_future_schedule_is_not_agent_visible` - - `tests/test_api.py::test_api_world_exposes_company_survival_gauntlet_state` - - `tests/test_experiments.py::test_run_matrix_company_gauntlet_artifact_includes_survival_metrics` - - `tests/test_experiments.py::test_aggregate_artifacts_includes_company_gauntlet_metrics` - - `tests/test_experiments.py::test_run_matrix_cli_company_gauntlet_smoke_builds_two_compare_runs` - - `frontend/src/components/CompanyGenesisPanel.test.ts` - -## Future Specs (Approved, Not Yet Implemented) - -These specs are in `specs/` with status markers. They are candidates for -future milestones and should be re-evaluated against the Living World -architecture before implementation. - -| Spec | Title | Priority | Dependencies | Notes | -|------|-------|----------|-------------|-------| -| 014 | Timeline Event Filters | P2 | 013 | Needs Living World dock integration | -| 015 | Timeline Advanced Query Presets | P3 | 014 | | -| 016 | Timeline Event Detail Drawer | P2 | 013 | Needs Living World detail card integration | -| 017 | Event Detail Structured Parsing | P2 | 016 | | -| 018 | Event Detail Context Links | P3 | 017 | | -| 019 | Event Investigation Shortcuts | P3 | 018 | | -| 020 | Event Bookmarks and Investigation Path | P2 | 013 | | -| 021 | Bookmark Tags and Groups | P3 | 020 | | -| 022 | Bookmark Import/Export | P3 | 021 | | -| 023 | Investigation Set Versioning | P3 | 022 | | -| 029 | Artifact Workbench | P2 | 028 | `ArtifactWorkbenchPanel.tsx` exists as stub | -| 030 | Statistical Inference v2 | P2 | 007 | | -| 031 | Large Run Operations | P3 | 006 | | -| 032 | Direct Broadcast Read | P2 | 013 | Spec references superseded 012; needs Living World redesign | - -## Archived Specs - -Obsolete, superseded, or early-draft specs moved to `specs/archive/`: - -| File | Reason | -|------|--------| -| `001-game-observer-ui.md` | Early UI concept, replaced by 012 → 013 | -| `001-observer-agent-free-walking.md` | Early movement concept, absorbed into engine | -| `001-real-llm.md` | Early LLM concept, implemented in `LLMClient` | -| `002-tick-perception-snapshot.md` | Merged into 001-spatial-graph and engine | -| `003-unified-config.md` | Config system built, documented in 008 | -| `004-observer-console.md` | Replaced by Living World (013) | -| `005-narrative-simulator-layer.md` | Absorbed into Living World feed + event log | -| `006-sandbox-experiment-controls.md` | Absorbed into experiment orchestration (006) | -| `007-live-observer-stream-and-replay-strip.md` | Replaced by Living World WebSocket + ReplayStripPanel | -| `012-agenttv-observer-ui-fit.md` | Superseded by 013 (Living World) | -| `012-ui-simplification.md` | Superseded by 013 (Living World) | -| `013-replay-comparison-panel.md` | Duplicate number; Living World took different direction | -| `observer-ui-hardcore.md` | Early design exploration, replaced by DESIGN.md + 013 | - -## Project Verification Snapshot - -Last recorded full verification: - -- 2026-05-03: CI pipeline active (GitHub Actions) — pytest, ruff, tsc, build on push/PR. -- 2026-04-27: `pytest tests/ -q` -> 263 passed. -- 2026-04-27: `python -m ruff check antelab/ tests/ scripts/` -> passed. -- Frontend verification: `npm run test && npm run typecheck && npm run build` -> 49 frontend tests passed. diff --git a/antelab/__init__.py b/antelab/__init__.py index 553bd03..d46b6de 100644 --- a/antelab/__init__.py +++ b/antelab/__init__.py @@ -1 +1,3 @@ -"""AnteLab — Foresight laboratory for multi-scenario agent simulation.""" +"""AnteLab digital evolution laboratory.""" + +__version__ = "0.2.0" diff --git a/antelab/api/__init__.py b/antelab/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/antelab/api/event_store.py b/antelab/api/event_store.py deleted file mode 100644 index 5a64aba..0000000 --- a/antelab/api/event_store.py +++ /dev/null @@ -1,361 +0,0 @@ -"""SQLite-backed passive event store for API-produced world snapshots.""" - -from __future__ import annotations - -import json -import re -import sqlite3 -import threading -from datetime import UTC, datetime -from hashlib import sha256 -from pathlib import Path -from typing import Any - -_TICK_RE = re.compile(r"^\[Tick\s+(\d+)\]") -SCHEMA_VERSION = 1 - - -class EventStore: - """Persist world snapshots and queryable event rows without affecting simulation.""" - - def __init__(self, path: Path | str, *, run_id: str) -> None: - self.path = Path(path) - self.run_id = run_id - self.path.parent.mkdir(parents=True, exist_ok=True) - self._lock = threading.RLock() - self._conn: sqlite3.Connection | None = sqlite3.connect( - self.path, - check_same_thread=False, - ) - self._conn.row_factory = sqlite3.Row - self._initialize() - - def _initialize(self) -> None: - conn = self._connection() - with self._lock, conn: - if self._has_legacy_schema(conn): - self._rebuild_legacy_schema(conn) - self._create_schema(conn) - conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") - - def _create_schema(self, conn: sqlite3.Connection) -> None: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS snapshots ( - run_id TEXT NOT NULL, - tick INTEGER NOT NULL, - world_json TEXT NOT NULL, - recorded_at TEXT NOT NULL, - PRIMARY KEY(run_id, tick) - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - tick INTEGER NOT NULL, - snapshot_tick INTEGER NOT NULL, - event_index INTEGER NOT NULL, - event TEXT NOT NULL, - visibility_json TEXT NOT NULL, - event_key TEXT NOT NULL, - recorded_at TEXT NOT NULL, - UNIQUE(run_id, event_key) - ) - """ - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_events_run_tick_id " - "ON events(run_id, tick, id)" - ) - - def _has_legacy_schema(self, conn: sqlite3.Connection) -> bool: - snapshots = self._table_columns(conn, "snapshots") - events = self._table_columns(conn, "events") - return (bool(snapshots) and "run_id" not in snapshots) or ( - bool(events) and ("run_id" not in events or "event_key" not in events) - ) - - def _rebuild_legacy_schema(self, conn: sqlite3.Connection) -> None: - conn.execute("ALTER TABLE snapshots RENAME TO snapshots_legacy") - conn.execute("ALTER TABLE events RENAME TO events_legacy") - self._create_schema(conn) - conn.execute( - """ - INSERT OR IGNORE INTO snapshots(run_id, tick, world_json, recorded_at) - SELECT 'legacy', tick, world_json, recorded_at - FROM snapshots_legacy - """ - ) - rows = conn.execute( - """ - SELECT tick, snapshot_tick, event_index, event, visibility_json, recorded_at - FROM events_legacy - ORDER BY id ASC - """ - ).fetchall() - conn.executemany( - """ - INSERT OR IGNORE INTO events( - run_id, - tick, - snapshot_tick, - event_index, - event, - visibility_json, - event_key, - recorded_at - ) - VALUES ('legacy', ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - int(row["tick"]), - int(row["snapshot_tick"]), - int(row["event_index"]), - str(row["event"]), - str(row["visibility_json"]), - self._event_key( - int(row["tick"]), - str(row["event"]), - str(row["visibility_json"]), - ), - str(row["recorded_at"]), - ) - for row in rows - ], - ) - conn.execute("DROP TABLE snapshots_legacy") - conn.execute("DROP TABLE events_legacy") - - def _table_columns(self, conn: sqlite3.Connection, table: str) -> set[str]: - return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} - - def set_run_id(self, run_id: str) -> None: - self.run_id = run_id - - def schema_version(self) -> int: - with self._lock: - row = self._connection().execute("PRAGMA user_version").fetchone() - return int(row[0]) - - def clear_current_run(self) -> None: - conn = self._connection() - with self._lock, conn: - conn.execute("DELETE FROM events WHERE run_id = ?", (self.run_id,)) - conn.execute("DELETE FROM snapshots WHERE run_id = ?", (self.run_id,)) - - @property - def is_open(self) -> bool: - return self._conn is not None - - def close(self) -> None: - with self._lock: - if self._conn is not None: - self._conn.close() - self._conn = None - - def record_world_snapshot(self, world: dict[str, Any]) -> None: - """Store a produced world snapshot plus deduplicated events derived from it.""" - tick = int(world.get("tick", 0)) - recorded_at = datetime.now(UTC).isoformat() - world_json = json.dumps(world, ensure_ascii=True, sort_keys=True) - conn = self._connection() - - with self._lock, conn: - conn.execute( - """ - INSERT INTO snapshots(run_id, tick, world_json, recorded_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(run_id, tick) DO UPDATE SET - world_json = excluded.world_json, - recorded_at = excluded.recorded_at - """, - (self.run_id, tick, world_json, recorded_at), - ) - conn.executemany( - """ - INSERT OR IGNORE INTO events( - run_id, - tick, - snapshot_tick, - event_index, - event, - visibility_json, - event_key, - recorded_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - self.run_id, - event_tick, - tick, - index, - event, - visibility_json, - self._event_key(event_tick, event, visibility_json), - recorded_at, - ) - for index, event_tick, event, visibility_json in self._iter_events( - world, - tick, - ) - ], - ) - - def get_snapshot(self, tick: int) -> dict[str, Any] | None: - with self._lock: - row = ( - self._connection() - .execute( - "SELECT world_json FROM snapshots WHERE run_id = ? AND tick = ?", - (self.run_id, tick), - ) - .fetchone() - ) - if row is None: - return None - payload = json.loads(str(row["world_json"])) - return payload if isinstance(payload, dict) else None - - def query_events( - self, - *, - from_tick: int | None = None, - to_tick: int | None = None, - limit: int = 100, - ) -> list[dict[str, Any]]: - clauses: list[str] = ["run_id = ?"] - params: list[Any] = [self.run_id] - if from_tick is not None: - clauses.append("tick >= ?") - params.append(from_tick) - if to_tick is not None: - clauses.append("tick <= ?") - params.append(to_tick) - - has_tick_filter = from_tick is not None or to_tick is not None - where = f"WHERE {' AND '.join(clauses)}" - order = "tick ASC, id ASC" if has_tick_filter else "tick DESC, id DESC" - with self._lock: - rows = ( - self._connection() - .execute( - f""" - SELECT id, tick, snapshot_tick, event, visibility_json, recorded_at - FROM events - {where} - ORDER BY {order} - LIMIT ? - """, - (*params, limit), - ) - .fetchall() - ) - events = [self._row_to_event(row) for row in rows] - return events if has_tick_filter else list(reversed(events)) - - def counts(self) -> dict[str, int]: - with self._lock: - conn = self._connection() - snapshot_count = conn.execute( - "SELECT COUNT(*) FROM snapshots WHERE run_id = ?", - (self.run_id,), - ).fetchone()[0] - event_count = conn.execute( - "SELECT COUNT(*) FROM events WHERE run_id = ?", - (self.run_id,), - ).fetchone()[0] - return {"snapshots": int(snapshot_count), "events": int(event_count)} - - def total_counts(self) -> dict[str, int]: - with self._lock: - conn = self._connection() - snapshot_count = conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] - event_count = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] - return {"snapshots": int(snapshot_count), "events": int(event_count)} - - def diagnostics(self) -> dict[str, Any]: - if not self.is_open: - return { - "path": str(self.path), - "open": False, - "run_id": self.run_id, - "schema_version": None, - "counts": {"snapshots": 0, "events": 0}, - "total_counts": {"snapshots": 0, "events": 0}, - } - return { - "path": str(self.path), - "open": self.is_open, - "run_id": self.run_id, - "schema_version": self.schema_version(), - "counts": self.counts(), - "total_counts": self.total_counts(), - } - - def _row_to_event(self, row: sqlite3.Row) -> dict[str, Any]: - visibility = json.loads(str(row["visibility_json"])) - return { - "id": int(row["id"]), - "tick": int(row["tick"]), - "snapshot_tick": int(row["snapshot_tick"]), - "event": str(row["event"]), - "visibility": visibility if isinstance(visibility, dict) else {}, - "recorded_at": str(row["recorded_at"]), - } - - def _connection(self) -> sqlite3.Connection: - if self._conn is None: - raise RuntimeError("EventStore is closed") - return self._conn - - def _iter_events( - self, - world: dict[str, Any], - snapshot_tick: int, - ) -> list[tuple[int, int, str, str]]: - raw_events = world.get("event_log") - if not isinstance(raw_events, list): - raw_events = world.get("recent_events", []) - if not isinstance(raw_events, list): - return [] - - events: list[tuple[int, int, str, str]] = [] - for index, raw in enumerate(raw_events): - event: str | None = None - visibility: dict[str, Any] = {} - if isinstance(raw, dict): - raw_event = raw.get("event") - if isinstance(raw_event, str): - event = raw_event - raw_visibility = raw.get("visibility") - if isinstance(raw_visibility, dict): - visibility = raw_visibility - elif isinstance(raw, str): - event = raw - - if event is None or not event.strip(): - continue - event_tick = self._event_tick(event, snapshot_tick) - visibility_json = json.dumps(visibility, ensure_ascii=True, sort_keys=True) - events.append((index, event_tick, event, visibility_json)) - return events - - def _event_key(self, tick: int, event: str, visibility_json: str) -> str: - payload = json.dumps( - {"tick": tick, "event": event, "visibility": visibility_json}, - ensure_ascii=True, - sort_keys=True, - ) - return sha256(payload.encode("utf-8")).hexdigest() - - def _event_tick(self, event: str, fallback_tick: int) -> int: - match = _TICK_RE.match(event) - if match is None: - return fallback_tick - return int(match.group(1)) diff --git a/antelab/api/server.py b/antelab/api/server.py deleted file mode 100644 index 87b24d8..0000000 --- a/antelab/api/server.py +++ /dev/null @@ -1,917 +0,0 @@ -"""FastAPI server exposing simulation state and controls.""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import random -import sqlite3 -import subprocess -import time -import uuid -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import asdict -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, Literal - -from fastapi import FastAPI, HTTPException, Query, Request, WebSocket, WebSocketDisconnect -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field - -from antelab.api.event_store import EventStore -from antelab.config.identity import config_hash -from antelab.config.loader import AnteLabConfig, load_config -from antelab.engine.agent import Agent -from antelab.engine.tick import TickRunner -from antelab.engine.world import ExperimentAxioms, LifecycleParams, World -from antelab.llm.client import LLMClient -from antelab.llm.narrative import NarrativeGenerator -from antelab.season_bundle import load_season_bundle, season_bundle_public_dict - -logger = logging.getLogger(__name__) - -_runner: TickRunner | None = None -_world: World | None = None -_tick_lock = asyncio.Lock() -_connections: set[WebSocket] = set() -_history: SnapshotHistory | None = None -_event_store: EventStore | None = None -_event_store_last_error: str | None = None -_run_meta: dict[str, Any] = {} -_active_config: AnteLabConfig | None = None - -# Audience vote tallies (in-memory; v0 demo — no auth or persistence). -_narrative_generator: NarrativeGenerator | None = None -_narrative_history: list[dict[str, Any]] = [] -MAX_NARRATIVE_HISTORY = 500 - -_audience_votes: dict[str, dict[str, int]] = {"eviction": {}, "shelter": {}} -# Per-client cooldown for POST /api/votes (IP + kind + target). -_audience_vote_last: dict[str, float] = {} -VOTE_COOLDOWN_SEC = 2.0 - - -class SnapshotHistory: - """In-memory tick-indexed snapshots with append-only persistence.""" - - def __init__( - self, - path: Path, - run_id: str, - max_entries: int = 500, - compact_every: int = 100, - ) -> None: - self.path = path - self.run_id = run_id - self.max_entries = max_entries - self.compact_every = max(1, compact_every) - self._since_compact = 0 - self._by_tick: dict[int, dict[str, Any]] = {} - self._load() - - def _load(self) -> None: - if not self.path.exists(): - return - for line in self.path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - try: - record = json.loads(line) - if record.get("run_id") != self.run_id: - continue - tick = int(record["tick"]) - world = record["world"] - self._by_tick[tick] = world - except (ValueError, KeyError, json.JSONDecodeError, TypeError): - continue - self._trim() - - def set_run_id(self, run_id: str) -> None: - self.run_id = run_id - self._by_tick.clear() - self._since_compact = 0 - - def _trim(self) -> None: - if len(self._by_tick) <= self.max_entries: - return - ticks = sorted(self._by_tick.keys()) - for tick in ticks[: len(self._by_tick) - self.max_entries]: - self._by_tick.pop(tick, None) - - def append(self, world: dict[str, Any]) -> None: - tick = int(world.get("tick", 0)) - self._by_tick[tick] = world - self._trim() - self._append_record({"run_id": self.run_id, "tick": tick, "world": world}) - self._since_compact += 1 - if self._since_compact >= self.compact_every: - self.compact() - self._since_compact = 0 - - def _append_record(self, record: dict[str, Any]) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - with self.path.open("a", encoding="utf-8") as f: - f.write(json.dumps(record, ensure_ascii=True)) - f.write("\n") - - def get(self, tick: int) -> dict[str, Any] | None: - return self._by_tick.get(tick) - - def list_ticks(self, limit: int = 20) -> list[int]: - ticks = sorted(self._by_tick.keys(), reverse=True) - return ticks[:limit] - - def meta(self) -> dict[str, Any]: - return { - "path": str(self.path), - "run_id": self.run_id, - "max_entries": self.max_entries, - "compact_every": self.compact_every, - "current_entries": len(self._by_tick), - } - - def metrics_timeline(self, limit: int = 100) -> list[dict[str, Any]]: - ticks = sorted(self._by_tick.keys(), reverse=True)[:limit] - points: list[dict[str, Any]] = [] - for tick in reversed(ticks): - world = self._by_tick[tick] - metrics = dict(world.get("metrics", {})) - points.append({"tick": tick, "metrics": metrics}) - return points - - def clear(self, keep_world: dict[str, Any] | None = None) -> None: - self._by_tick.clear() - if keep_world is not None: - tick = int(keep_world.get("tick", 0)) - self._by_tick[tick] = keep_world - self.compact() - - def compact(self) -> None: - if not self._by_tick: - if self.path.exists(): - self.path.unlink() - return - self.path.parent.mkdir(parents=True, exist_ok=True) - with self.path.open("w", encoding="utf-8") as f: - for tick in sorted(self._by_tick.keys()): - record = { - "run_id": self.run_id, - "tick": tick, - "world": self._by_tick[tick], - } - f.write(json.dumps(record, ensure_ascii=True)) - f.write("\n") - -async def _broadcast_world_snapshot() -> None: - """Push current world state to all connected websocket clients.""" - if _world is None or not _connections: - return - - message: dict[str, Any] = {"type": "world_snapshot", "world": _world.to_dict()} - if _narrative_history: - message["narrative"] = _narrative_history[-1] - stale: list[WebSocket] = [] - - # Iterate over a snapshot because the websocket set can change while we await send_json. - for ws in tuple(_connections): - try: - await ws.send_json(message) - except (RuntimeError, WebSocketDisconnect): - stale.append(ws) - - for ws in stale: - _connections.discard(ws) - - -def _record_world_snapshot() -> dict[str, Any]: - assert _world is not None and _history is not None - world = _world.to_dict() - _history.append(world) - _record_event_store_snapshot(world) - return world - - -def _record_event_store_snapshot(world: dict[str, Any]) -> None: - global _event_store_last_error - if _event_store is not None: - try: - _event_store.record_world_snapshot(world) - _event_store_last_error = None - except (sqlite3.Error, RuntimeError, OSError) as exc: - _event_store_last_error = f"{type(exc).__name__}: {exc}" - logger.exception("Passive event-store write failed") - - -def _event_store_path(history_path: Path) -> Path: - override = os.environ.get("ANTELAB_EVENT_STORE_PATH") - if override: - return Path(override) - return history_path.with_suffix(".sqlite3") - - -def _build_runner_for_loaded_world( - world: World, - agents_private: dict[str, Any] | None = None, -) -> TickRunner: - assert _active_config is not None - llm = LLMClient( - mode=_active_config.llm.mode, - model=_active_config.llm.model, - temperature=_active_config.llm.temperature, - max_tokens=_active_config.llm.max_tokens, - ) - agents: list[Agent] = [] - for state in sorted(world.agents.values(), key=lambda s: s.id): - agent = Agent.create( - name=state.name, - personality="Recovered agent from persisted world state.", - llm=llm, - memory_size=world.axioms.memory_size, - ) - agent.id = state.id - if agents_private: - blob = agents_private.get(state.id) - if isinstance(blob, dict): - mem = blob.get("memory_events") - pers = blob.get("personality") - pers_ok = isinstance(pers, str) and pers.strip() - mem_ok = isinstance(mem, list) - if mem_ok or pers_ok: - agent.restore_private_state( - personality=pers if pers_ok else None, - memory_events=[str(x) for x in mem] if mem_ok else None, - ) - agents.append(agent) - return TickRunner(world, agents, template_llm=llm) - - -def _serialize_agents_private(runner: TickRunner) -> dict[str, Any]: - out: dict[str, Any] = {} - for agent in runner.agents: - out[agent.id] = { - "personality": agent.personality, - "memory_events": list(agent.memory.events), - } - return out - - -def _get_git_sha() -> str | None: - try: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - check=True, - capture_output=True, - text=True, - ) - except (OSError, subprocess.SubprocessError): - return None - return result.stdout.strip() or None - - -def _build_run_meta(cfg: AnteLabConfig, config_path: str | None) -> dict[str, Any]: - return { - "run_id": str(uuid.uuid4()), - "started_at": datetime.now(UTC).isoformat(), - "seed": cfg.experiment.seed, - "experiment_name": cfg.experiment.name, - "llm_mode": cfg.llm.mode, - "llm_model": cfg.llm.model, - "config_path": config_path or "", - "config_hash": config_hash(cfg), - "git_sha": _get_git_sha(), - } - - -def _bootstrap(config: AnteLabConfig | None = None) -> tuple[World, TickRunner]: - global _history, _event_store, _event_store_last_error, _run_meta, _active_config - if config is None: - cfg = load_config() - config_path = str(Path.cwd() / "antelab/config/default.yaml") - else: - cfg = config - config_path = None - _active_config = cfg - random.seed(cfg.experiment.seed) - - logging.basicConfig( - level=getattr(logging, cfg.server.log_level.upper(), logging.INFO), - format="%(asctime)s %(name)s %(levelname)s %(message)s", - force=True, - ) - - world = World( - name=cfg.world.name, - locations=cfg.world.initial_locations, - location_graph=cfg.world.location_graph, - resource_zones={ - location: {item: dict(spec) for item, spec in resources.items()} - for location, resources in cfg.world.resource_zones.items() - }, - recipes=cfg.world.recipes, - event_log_limit=cfg.world.event_log_limit, - axioms=ExperimentAxioms( - perception=cfg.experiment.perception, - communication=cfg.experiment.communication, - social_tracking=cfg.experiment.social_tracking, - auto_eat=cfg.experiment.auto_eat, - mortality=cfg.experiment.mortality, - memory_size=cfg.experiment.memory_size, - ), - lifecycle=LifecycleParams( - age_tick_step=cfg.lifecycle.age_tick_step, - life_stage_thresholds=dict(cfg.lifecycle.life_stage_thresholds), - vitality_loss_per_tick=cfg.lifecycle.vitality_loss_per_tick, - vitality_rest_gain=cfg.lifecycle.vitality_rest_gain, - stress_gain_per_tick=cfg.lifecycle.stress_gain_per_tick, - stress_rest_reduction=cfg.lifecycle.stress_rest_reduction, - disease_exposure_threshold=cfg.lifecycle.disease_exposure_threshold, - disease_vitality_penalty=cfg.lifecycle.disease_vitality_penalty, - disease_stress_penalty=cfg.lifecycle.disease_stress_penalty, - disease_recovery_ticks=cfg.lifecycle.disease_recovery_ticks, - disease_transmission_base_chance=cfg.lifecycle.disease_transmission_base_chance, - disease_contact_weight=cfg.lifecycle.disease_contact_weight, - disease_exposure_weight=cfg.lifecycle.disease_exposure_weight, - disease_resilience_protection_weight=( - cfg.lifecycle.disease_resilience_protection_weight - ), - disease_need_vulnerability_weight=( - cfg.lifecycle.disease_need_vulnerability_weight - ), - disease_recovery_base_chance=cfg.lifecycle.disease_recovery_base_chance, - disease_recovery_resilience_weight=( - cfg.lifecycle.disease_recovery_resilience_weight - ), - disease_recovery_rest_bonus=cfg.lifecycle.disease_recovery_rest_bonus, - disease_exposure_decay_per_tick=cfg.lifecycle.disease_exposure_decay_per_tick, - hunger_gain_per_tick=cfg.lifecycle.hunger_gain_per_tick, - hunger_rest_reduction=cfg.lifecycle.hunger_rest_reduction, - fatigue_gain_per_tick=cfg.lifecycle.fatigue_gain_per_tick, - fatigue_rest_reduction=cfg.lifecycle.fatigue_rest_reduction, - hunger_vitality_penalty_threshold=( - cfg.lifecycle.hunger_vitality_penalty_threshold - ), - fatigue_stress_penalty_threshold=( - cfg.lifecycle.fatigue_stress_penalty_threshold - ), - needs_penalty=cfg.lifecycle.needs_penalty, - auto_eat_hunger_threshold=cfg.lifecycle.auto_eat_hunger_threshold, - nourishment_gain_per_food=cfg.lifecycle.nourishment_gain_per_food, - food_items=tuple(cfg.lifecycle.food_items), - conception_base_chance=cfg.lifecycle.conception_base_chance, - conception_vitality_weight=cfg.lifecycle.conception_vitality_weight, - conception_stress_weight=cfg.lifecycle.conception_stress_weight, - conception_hunger_weight=cfg.lifecycle.conception_hunger_weight, - conception_infection_penalty=cfg.lifecycle.conception_infection_penalty, - conception_trust_weight=cfg.lifecycle.conception_trust_weight, - conception_obligation_weight=cfg.lifecycle.conception_obligation_weight, - conception_min_vitality=cfg.lifecycle.conception_min_vitality, - conception_max_stress=cfg.lifecycle.conception_max_stress, - pregnancy_duration_min_ticks=cfg.lifecycle.pregnancy_duration_min_ticks, - pregnancy_duration_max_ticks=cfg.lifecycle.pregnancy_duration_max_ticks, - social_memory_max_entries=cfg.lifecycle.social_memory_max_entries, - ), - pressure=asdict(cfg.pressure), - company=asdict(cfg.company), - ) - - for loc, items in cfg.world.location_items.items(): - world.location_items[loc] = dict(items) - - llm = LLMClient( - mode=cfg.llm.mode, - model=cfg.llm.model, - temperature=cfg.llm.temperature, - max_tokens=cfg.llm.max_tokens, - ) - - agents: list[Agent] = [] - for defn in cfg.agents: - agent = Agent.create( - defn.name, defn.personality, llm, - memory_size=cfg.experiment.memory_size, - ) - world.register_agent( - agent.id, agent.name, - location=defn.location, - inventory=defn.inventory, - ) - agents.append(agent) - - runner = TickRunner(world, agents, template_llm=llm) - - global _narrative_generator, _narrative_history - narrative_llm = LLMClient( - mode=cfg.llm.mode, - model=cfg.llm.model if cfg.narrative.model == "inherit" else cfg.narrative.model, - temperature=cfg.narrative.temperature, - max_tokens=cfg.narrative.max_tokens, - ) - _narrative_generator = NarrativeGenerator(narrative_llm, config=cfg.narrative) - _narrative_history = [] - - history_path = Path(cfg.history.path) - _run_meta = _build_run_meta(cfg, config_path=config_path) - _history = SnapshotHistory( - path=history_path, - run_id=_run_meta["run_id"], - max_entries=cfg.history.max_entries, - compact_every=cfg.history.compact_every, - ) - if _event_store is not None: - _event_store.close() - _event_store = EventStore(_event_store_path(history_path), run_id=_run_meta["run_id"]) - _event_store_last_error = None - return world, runner - - -async def _generate_narrative() -> dict[str, Any] | None: - """Generate and store narrative for the current tick.""" - global _narrative_generator, _narrative_history - if _narrative_generator is None or _world is None: - return None - - world_dict = _world.to_dict() - agents_list: list[dict[str, Any]] = [] - for a in world_dict.get("agents", []): - agents_list.append({ - "name": a.get("name", "?"), - "location": a.get("location", "unknown"), - "last_action": a.get("last_action", ""), - "alive": a.get("alive", True), - }) - - locations = list(world_dict.get("locations", [])) - events = list(world_dict.get("recent_events", [])) - company = world_dict.get("company") - - entry = await _narrative_generator.generate( - tick=_world.tick, - events=events, - agents=agents_list, - locations=locations, - company_summary=company, - ) - - if entry.text: - d = entry.to_dict() - _narrative_history.append(d) - if len(_narrative_history) > MAX_NARRATIVE_HISTORY: - _narrative_history = _narrative_history[-MAX_NARRATIVE_HISTORY:] - return d - return None - - -def _ensure_bootstrap() -> None: - global _world, _runner - if _world is None or _runner is None: - _world, _runner = _bootstrap() - _record_world_snapshot() - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None]: - global _world, _runner, _event_store - _world, _runner = _bootstrap() - _record_world_snapshot() - try: - yield - finally: - if _event_store is not None: - _event_store.close() - _event_store = None - - -app = FastAPI(title="AnteLab", version="0.1.0", lifespan=lifespan) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/api/world") -async def get_world() -> dict[str, Any]: - """Return the current world state (aligned with tick/save/load via shared lock).""" - async with _tick_lock: - assert _world is not None - return _world.to_dict() - - -@app.get("/api/run") -async def get_run_metadata() -> dict[str, Any]: - return dict(_run_meta) - - -@app.get("/api/scenario") -def get_scenario() -> dict[str, Any]: - cfg = _active_config or load_config() - return { - "id": cfg.scenario.id, - "title": cfg.scenario.title, - "hypothesis": cfg.scenario.hypothesis, - "counter_hypothesis": cfg.scenario.counter_hypothesis, - "tags": cfg.scenario.tags, - "long_run": asdict(cfg.long_run), - "pressure": asdict(cfg.pressure), - } - - -@app.get("/api/history") -async def get_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[str, Any]: - assert _history is not None - ticks = _history.list_ticks(limit=limit) - return {"ticks": ticks, "latest_tick": ticks[0] if ticks else None} - - -@app.get("/api/history/meta") -async def get_history_meta() -> dict[str, Any]: - assert _history is not None - return _history.meta() - - -@app.get("/api/metrics/timeline") -async def get_metrics_timeline( - limit: int = Query(default=100, ge=1, le=1000), -) -> dict[str, Any]: - assert _history is not None - return { - "points": _history.metrics_timeline(limit=limit), - } - - -@app.get("/api/events") -async def get_events( - from_tick: int | None = Query(default=None, ge=0), - to_tick: int | None = Query(default=None, ge=0), - limit: int = Query(default=100, ge=1, le=1000), -) -> dict[str, Any]: - assert _event_store is not None - if from_tick is not None and to_tick is not None and from_tick > to_tick: - raise HTTPException(status_code=400, detail="from_tick cannot be greater than to_tick") - return { - "events": _event_store.query_events( - from_tick=from_tick, - to_tick=to_tick, - limit=limit, - ) - } - - -@app.get("/api/measurements/series") -def get_measurement_series() -> dict[str, Any]: - _ensure_bootstrap() - assert _runner is not None - return {"ticks": _runner.observer.to_json()} - - -@app.get("/api/history/{tick}") -async def get_history_tick(tick: int) -> dict[str, Any]: - assert _history is not None - world = _history.get(tick) - if world is None: - raise HTTPException(status_code=404, detail=f"Tick {tick} not found in history") - return {"tick": tick, "world": world} - - -@app.post("/api/history/compact") -async def compact_history() -> dict[str, Any]: - assert _history is not None - _history.compact() - ticks = _history.list_ticks(limit=200) - return {"status": "ok", "ticks": ticks} - - -@app.post("/api/history/clear") -async def clear_history(keep_latest: bool = Query(default=True)) -> dict[str, Any]: - assert _history is not None and _world is not None - keep_world = _world.to_dict() if keep_latest else None - _history.clear(keep_world=keep_world) - _clear_event_store_history(keep_world=keep_world) - ticks = _history.list_ticks(limit=200) - return {"status": "ok", "ticks": ticks} - - -def _clear_event_store_history(keep_world: dict[str, Any] | None) -> None: - global _event_store_last_error - if _event_store is None: - return - try: - _event_store.clear_current_run() - if keep_world is not None: - _event_store.record_world_snapshot(keep_world) - _event_store_last_error = None - except (sqlite3.Error, RuntimeError, OSError) as exc: - _event_store_last_error = f"{type(exc).__name__}: {exc}" - logger.exception("Passive event-store clear failed") - - -def _apply_audience_votes_from_snapshot_payload(payload: dict[str, Any]) -> None: - """Restore in-memory audience tallies from a snapshot file (or clear if missing / invalid).""" - _audience_vote_last.clear() - raw = payload.get("audience_votes") - if raw is None: - _audience_votes["eviction"].clear() - _audience_votes["shelter"].clear() - return - if not isinstance(raw, dict): - _audience_votes["eviction"].clear() - _audience_votes["shelter"].clear() - return - ev_o = raw.get("eviction") - sh_o = raw.get("shelter") - if not isinstance(ev_o, dict) or not isinstance(sh_o, dict): - _audience_votes["eviction"].clear() - _audience_votes["shelter"].clear() - return - - def _int_dict(d: dict[Any, Any]) -> dict[str, int]: - out: dict[str, int] = {} - for k, v in d.items(): - try: - out[str(k)] = int(v) - except (TypeError, ValueError): - continue - return out - - _audience_votes["eviction"] = _int_dict(ev_o) - _audience_votes["shelter"] = _int_dict(sh_o) - - -@app.post("/api/save") -async def save_snapshot() -> dict[str, Any]: - assert _world is not None and _history is not None and _runner is not None - snapshot_dir = _history.path.parent / "snapshots" - snapshot_dir.mkdir(parents=True, exist_ok=True) - filename = f"{_run_meta.get('run_id', 'run')}-tick-{_world.tick}.json" - target = snapshot_dir / filename - async with _tick_lock: - payload = { - "snapshot_version": 2, - "run": dict(_run_meta), - "world": _world.to_dict(), - "audience_votes": { - "eviction": dict(_audience_votes["eviction"]), - "shelter": dict(_audience_votes["shelter"]), - }, - "agents_private": _serialize_agents_private(_runner), - } - target.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8") - return {"status": "ok", "path": str(target)} - - -@app.post("/api/load") -async def load_snapshot(path: str = Query(..., min_length=1)) -> dict[str, Any]: - global _world, _runner, _run_meta, _event_store, _event_store_last_error - target = Path(path) - if not target.exists(): - raise HTTPException(status_code=400, detail=f"Snapshot not found: {target}") - try: - payload = json.loads(target.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise HTTPException(status_code=400, detail=f"Invalid snapshot JSON: {exc}") from exc - world_payload = payload.get("world") - if not isinstance(world_payload, dict): - raise HTTPException(status_code=400, detail="Snapshot missing 'world' object") - - async with _tick_lock: - _world = World.from_dict(world_payload) - raw_private = payload.get("agents_private") - agents_private = raw_private if isinstance(raw_private, dict) else None - _runner = _build_runner_for_loaded_world(_world, agents_private=agents_private) - _apply_audience_votes_from_snapshot_payload(payload) - parent_run = payload.get("run") - parent_run_id = ( - parent_run.get("run_id") - if isinstance(parent_run, dict) and isinstance(parent_run.get("run_id"), str) - else _run_meta.get("run_id") - ) - _run_meta = { - **dict(_run_meta), - "run_id": str(uuid.uuid4()), - "started_at": datetime.now(UTC).isoformat(), - "loaded_from_run_id": parent_run_id, - "loaded_from_snapshot": str(target), - } - if _event_store is not None: - _event_store.set_run_id(_run_meta["run_id"]) - _event_store_last_error = None - if _history is not None: - _history.set_run_id(_run_meta["run_id"]) - world_out = _record_world_snapshot() - await _broadcast_world_snapshot() - return {"status": "ok", "tick": world_out["tick"], "world": world_out} - - -@app.post("/api/tick") -async def run_tick() -> dict[str, Any]: - """Advance the simulation by one tick.""" - assert _runner is not None and _world is not None - async with _tick_lock: - result = await _runner.run_tick() - world = _record_world_snapshot() - narrative = await _generate_narrative() - await _broadcast_world_snapshot() - response: dict[str, Any] = { - "tick": result.tick, - "results": [ - {"success": r.success, "description": r.description} - for r in result.results - ], - "world": world, - } - if narrative: - response["narrative"] = narrative - return response - - -class VoteRequest(BaseModel): - """Audience poll: who to evict next vs who gets shelter priority.""" - - kind: Literal["eviction", "shelter"] - agent_id: str = Field(..., min_length=1) - - -class DemoRunRequest(BaseModel): - ticks: int = Field(default=50, ge=1, le=200) - - -@app.post("/api/demo/run") -async def demo_run(req: DemoRunRequest) -> dict[str, Any]: - """Run N ticks in batch for headless demo frame generation.""" - assert _runner is not None and _world is not None - for _ in range(req.ticks): - await _runner.run_tick() - world = _record_world_snapshot() - await _generate_narrative() - await _broadcast_world_snapshot() - return world - - -def _client_host(request: Request) -> str: - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - if request.client: - return request.client.host - return "unknown" - - -def _vote_snapshot() -> dict[str, Any]: - assert _world is not None - return { - "eviction": dict(_audience_votes["eviction"]), - "shelter": dict(_audience_votes["shelter"]), - "agents": [ - {"id": a.id, "name": a.name, "alive": a.alive} - for a in _world.agents.values() - ], - "tick": _world.tick, - } - - -@app.get("/api/votes") -async def get_audience_votes() -> dict[str, Any]: - """Return in-memory eviction / shelter tallies for the current run.""" - assert _world is not None - return _vote_snapshot() - - -@app.post("/api/votes") -async def post_audience_vote(request: Request, body: VoteRequest) -> dict[str, Any]: - """Increment a vote for an alive cast member (light per-IP cooldown).""" - assert _world is not None - state = _world.agents.get(body.agent_id) - if state is None or not state.alive: - raise HTTPException(status_code=400, detail="Unknown or eliminated cast member") - rate_key = f"{_client_host(request)}:{body.kind}:{body.agent_id}" - now = time.monotonic() - last = _audience_vote_last.get(rate_key, 0.0) - if now - last < VOTE_COOLDOWN_SEC: - raise HTTPException(status_code=429, detail="Slow down — try again in a moment") - _audience_vote_last[rate_key] = now - bucket = _audience_votes[body.kind] - bucket[body.agent_id] = bucket.get(body.agent_id, 0) + 1 - out = _vote_snapshot() - out["ok"] = True - return out - - -@app.get("/api/experiment") -async def get_experiment() -> dict[str, Any]: - """Return the current experiment configuration and measurements.""" - assert _runner is not None and _world is not None - return { - "axioms": _world.to_dict()["experiment"], - "measurements": _runner.observer.summary(), - } - - -@app.get("/api/season/bundle") -async def get_season_bundle() -> dict[str, Any]: - """Active season manifest: experiments, cast order, titles. Paths are repo-relative.""" - bundle = load_season_bundle() - return season_bundle_public_dict(bundle) - - -@app.get("/api/health") -async def health() -> dict[str, str]: - return {"status": "ok"} - - -@app.get("/api/health/runtime") -async def runtime_health() -> dict[str, Any]: - assert _world is not None - alive = sum(1 for state in _world.agents.values() if state.alive) - event_store = _event_store_diagnostics() - return { - "status": "ok", - "tick": _world.tick, - "agent_count": len(_world.agents), - "alive_count": alive, - "event_log_entries": len(_world.event_log), - "event_log_limit": _world.event_log_limit, - "event_store": event_store, - } - - -def _event_store_diagnostics() -> dict[str, Any]: - if _event_store is None: - return { - "path": None, - "open": False, - "run_id": _run_meta.get("run_id"), - "schema_version": None, - "counts": {"snapshots": 0, "events": 0}, - "total_counts": {"snapshots": 0, "events": 0}, - "degraded": _event_store_last_error is not None, - "last_error": _event_store_last_error, - "multiprocess_note": "SQLite event store is process-local; use one API writer.", - } - try: - diagnostics = _event_store.diagnostics() - except (sqlite3.Error, RuntimeError, OSError) as exc: - error = f"{type(exc).__name__}: {exc}" - logger.exception("Passive event-store diagnostics failed") - return { - "path": str(_event_store.path), - "open": _event_store.is_open, - "run_id": _run_meta.get("run_id"), - "schema_version": None, - "counts": {"snapshots": 0, "events": 0}, - "total_counts": {"snapshots": 0, "events": 0}, - "degraded": True, - "last_error": error, - "multiprocess_note": "SQLite event store is process-local; use one API writer.", - } - diagnostics["degraded"] = _event_store_last_error is not None - diagnostics["last_error"] = _event_store_last_error - diagnostics["multiprocess_note"] = "SQLite event store is process-local; use one API writer." - return diagnostics - - -@app.get("/api/narrative") -async def get_narrative( - limit: int = Query(default=1, ge=1, le=100), -) -> dict[str, Any]: - """Return the latest narrative entry (or N most recent).""" - entries = _narrative_history[-limit:] if _narrative_history else [] - return { - "entries": entries, - "latest": entries[-1] if entries else None, - "total": len(_narrative_history), - } - - -@app.get("/api/narrative/history") -async def get_narrative_history( - from_tick: int | None = Query(default=None, ge=0), - to_tick: int | None = Query(default=None, ge=0), - limit: int = Query(default=50, ge=1, le=500), -) -> dict[str, Any]: - """Return narrative entries within a tick range.""" - entries = _narrative_history - if from_tick is not None: - entries = [e for e in entries if e["tick"] >= from_tick] - if to_tick is not None: - entries = [e for e in entries if e["tick"] <= to_tick] - entries = entries[-limit:] - return {"entries": entries, "total": len(_narrative_history)} - - -@app.websocket("/ws/world") -async def world_stream(websocket: WebSocket) -> None: - """Stream world snapshots to connected observers.""" - await websocket.accept() - _connections.add(websocket) - await _broadcast_world_snapshot() - try: - while True: - await websocket.receive_text() - except WebSocketDisconnect: - _connections.discard(websocket) diff --git a/antelab/config/__init__.py b/antelab/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/antelab/config/company.yaml b/antelab/config/company.yaml deleted file mode 100644 index 6382baa..0000000 --- a/antelab/config/company.yaml +++ /dev/null @@ -1,152 +0,0 @@ -# ============================================================================= -# AnteLab Company Simulation Configuration -# ============================================================================= -# Loaded as an independent config layer for company emergence experiments. -# All thresholds, shock pools, and parameters live here so experiments can -# override them without touching engine code. -# -# For experiment files that extend default.yaml, the company section below -# is merged via the standard extends chain. - -company: - - # --- Stage progression thresholds --- - # Company advances to the next stage when these minimums are met. - stages: ["garage", "workshop", "formal", "scale", "mature"] - - stage_thresholds: - team_size_min: - workshop: 3 - formal: 6 - scale: 12 - mature: 20 - departments_min: - formal: 1 - scale: 2 - mature: 4 - cumulative_revenue_min: - workshop: 50 - formal: 200 - scale: 1000 - mature: 5000 - - # --- Market system --- - market: - demand_difficulty_by_stage: - garage: 1 - workshop: 2 - formal: 3 - scale: 4 - mature: 5 - max_open_demands: 5 - demand_generation_every: 3 - reward_base: - garage: 5 - workshop: 15 - formal: 40 - scale: 100 - mature: 500 - reward_spread: 0.5 - deadline_ticks_base: - garage: 10 - workshop: 15 - formal: 20 - scale: 30 - mature: 50 - shocks: - garage: [] - workshop: - - kind: demand_shift - weight: 0.3 - description: "A key client changes requirements mid-project." - - kind: economic_downturn - weight: 0.2 - description: "Local economy tightens; customer budgets shrink." - formal: - - kind: demand_shift - weight: 0.25 - description: "Enterprise client mandates new compliance rules." - - kind: competitor_entry - weight: 0.2 - description: "A well-funded competitor enters the market." - - kind: economic_downturn - weight: 0.15 - description: "Sector-wide downturn hits recurring revenue." - scale: - - kind: demand_shift - weight: 0.2 - description: "Regulatory change forces product adaptation." - - kind: competitor_entry - weight: 0.3 - description: "Multiple competitors launch competing products." - - kind: boom - weight: 0.25 - description: "Market tailwind doubles demand temporarily." - - kind: economic_downturn - weight: 0.1 - description: "Macroeconomic shock reduces enterprise spending." - mature: - - kind: competitor_entry - weight: 0.2 - description: "Disruptive startup challenges core business line." - - kind: economic_downturn - weight: 0.25 - description: "Global recession pressures multi-year contracts." - - kind: boom - weight: 0.3 - description: "Strategic partnership unlocks new market segment." - - kind: demand_shift - weight: 0.15 - description: "Technology inflection point shifts customer needs." - global: - - kind: economic_downturn - weight: 0.1 - description: "Cross-sector uncertainty dampens all demand." - - # --- Pattern detector --- - pattern: - window_ticks: 20 - threshold: 5 - categories: - - DELIVERY - - CRAFTING - - COORDINATION - - DELEGATION - - PLANNING - suggestion_expiry_ticks: 5 - - # --- Organization system --- - org: - max_departments: 12 - auto_departments_from_suggestions: true - min_members_for_team: 2 - - # --- Valuation model --- - valuation: - ipo_valuation_target: 10000000000 - ipo_consecutive_profitable_ticks: 5 - ipo_min_team_size: 5 - ipo_min_departments: 1 - weights: - cumulative_revenue: 10.0 - revenue_growth_rate: 100.0 - team_size: 5.0 - org_complexity: 2.0 - demand_completion_rate: 50.0 - cash_reserve: 2.0 - growth_rate_window_ticks: 10 - - # --- Office space --- - space: - stages: ["garage", "office", "floor", "campus"] - capacity_by_stage: - garage: 3 - office: 8 - floor: 20 - campus: 50 - expansion_cost_by_stage: - office: 50 - floor: 200 - campus: 1000 - capacity_pressure_ticks: 3 - stress_per_overcapacity: 2 diff --git a/antelab/config/default.yaml b/antelab/config/default.yaml deleted file mode 100644 index e7211c9..0000000 --- a/antelab/config/default.yaml +++ /dev/null @@ -1,191 +0,0 @@ -# ============================================================================= -# AnteLab Configuration -# ============================================================================= - -world: - name: "AnteLab World" # Display name for the simulation - - initial_locations: # Flat list of location IDs (no adjacency yet) - - "town_square" - - "market" - - "residential_area" - - location_graph: # Spatial adjacency map (fallback: fully connected) - town_square: ["market", "residential_area"] - market: ["town_square"] - residential_area: ["town_square"] - - location_items: # Resources placed at locations at startup - market: # key = location ID - bread: 10 # value = { item_name: quantity } - apple: 5 - town_square: - stone: 20 - - recipes: # Crafting recipes (resource conservation) - bread_bundle: - inputs: - wheat: 2 - outputs: - bread: 1 - - event_log_limit: 2000 # Keep latest N world events in memory - -scenario: - id: baseline - title: Baseline - hypothesis: Default physics produce the control run. - counter_hypothesis: Default physics are insufficient for stable emergence. - tags: [baseline] - -long_run: - ticks: 1000 - seed: 42 - benchmark_agents: 30 - diagnostics_every: 100 - artifact_every: 10 - -pressure: - survival: - enabled: true - resources: - enabled: true - decay_every: 0 - storage_decay_multiplier: 0.25 - regeneration_every: 0 - disease: - enabled: true - environment: - enabled: false - season_length_ticks: 500 - -llm: - mode: "mock" # "mock" = deterministic dev mode (no API calls) - # "openai" = OpenAI API (requires OPENAI_API_KEY) - # "anthropic" = Anthropic API (requires ANTHROPIC_API_KEY) - model: "gpt-4o-mini" # Model name (used when mode != "mock") - temperature: 0.7 # LLM sampling temperature - max_tokens: 512 # Max tokens per LLM response - -server: - host: "127.0.0.1" # API server bind address ("0.0.0.0" for Docker) - port: 8080 # API server port - log_level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR - -lifecycle: - age_tick_step: 1 # Age progression applied each world tick - life_stage_thresholds: # age_ticks boundary for stage transitions - juvenile: 20 - adult: 60 - elder: 360 - vitality_loss_per_tick: 1 # Baseline vitality decay per tick - vitality_rest_gain: 2 # Vitality recovered when an agent rests - stress_gain_per_tick: 1 # Baseline stress increase when active - stress_rest_reduction: 2 # Stress reduced by resting - disease_exposure_threshold: 3 # Local exposure count before infection - disease_vitality_penalty: 2 # Additional vitality loss while infected - disease_stress_penalty: 1 # Additional stress gain while infected - disease_recovery_ticks: 8 # Infected duration before deterministic recovery - disease_transmission_base_chance: 0.08 # Baseline per-tick local transmission probability - disease_contact_weight: 0.22 # Transmission boost per infectious neighbor - disease_exposure_weight: 0.1 # Transmission boost per accumulated exposure - disease_resilience_protection_weight: 0.5 # Immunity protection against transmission - disease_need_vulnerability_weight: 0.25 # Needs/stress vulnerability influence on transmission - disease_recovery_base_chance: 0.05 # Baseline probabilistic recovery each tick - disease_recovery_resilience_weight: 0.45 # Recovery boost from immune resilience - disease_recovery_rest_bonus: 0.2 # Recovery bonus when resting - disease_exposure_decay_per_tick: 1 # Exposure decay while not near infectious peers - hunger_gain_per_tick: 1 # Hunger increase while living/acting - hunger_rest_reduction: 1 # Hunger relief per rest tick - fatigue_gain_per_tick: 1 # Fatigue increase while active - fatigue_rest_reduction: 3 # Fatigue relief per rest tick - hunger_vitality_penalty_threshold: 70 # Hunger above threshold drains vitality - fatigue_stress_penalty_threshold: 70 # Fatigue above threshold increases stress - needs_penalty: 2 # Penalty magnitude for unmet needs - auto_eat_hunger_threshold: 50 # Hunger threshold to auto-consume carried food - nourishment_gain_per_food: 20 # Hunger reduced per consumed food unit - food_items: ["bread", "apple"] # Items treated as edible nourishment - conception_base_chance: 0.06 # Baseline pregnancy chance per eligible local pair tick - conception_vitality_weight: 0.45 # Positive fertility pressure from pair vitality - conception_stress_weight: 0.35 # Stress penalty applied to pair conception chance - conception_hunger_weight: 0.25 # Hunger penalty applied to pair conception chance - conception_infection_penalty: 0.4 # Infection penalty per infectious participant - conception_trust_weight: 0.15 # Trust bonus applied to pair conception chance - conception_obligation_weight: 0.08 # Obligation friction applied to pair conception chance - conception_min_vitality: 55 # Minimum vitality needed for conception - conception_max_stress: 60 # Maximum stress allowed for conception - pregnancy_duration_min_ticks: 16 # Minimum pregnancy duration (stochastic) - pregnancy_duration_max_ticks: 32 # Maximum pregnancy duration (stochastic) - social_memory_max_entries: 64 # Bound for trust/obligation maps per agent - -# --- Experiment Configuration (Civilization Laboratory) --- -experiment: - name: "baseline" # Experiment identifier - description: "Default physics — control group" - seed: 42 # Random seed for reproducibility - - axioms: # Physics dials — each can be varied per experiment - perception: "local" # local | global - communication: "colocated" # colocated | broadcast | silent - social_tracking: true # true = engine tracks trust/obligation | false = pure physics - auto_eat: false # true = engine auto-consumes food | false = agent-decided - mortality: true # true = agents can die | false = immortal - memory_size: 50 # Agent memory buffer size (events) - -narrative: - enabled: true # Generate LLM narrative commentary - model: "inherit" # LLM model (inherit uses llm.mode setting) - max_tokens: 256 # Max tokens per narrative generation - temperature: 0.7 # Narrative creativity (0=terse, 1=colorful) - generate_every_ticks: 1 # Generate every N ticks - -history: - path: "" # Optional snapshot store path (empty => OS temp path) - max_entries: 500 # Keep latest N snapshots in memory - compact_every: 100 # Rewrite history file every N appends - -agents: # Agent definitions — loaded at startup - - name: "Marcus" - personality: "Charming trader; weighs every favor in food and remembers every debt." - location: "town_square" - inventory: - bread: 1 - - - name: "Sarah" - personality: "Observant, conflicted witness; carries a secret about the group's first fracture." - location: "town_square" - inventory: - apple: 1 - - - name: "Eli" - personality: "Reserved strategist; speaks rarely, plans several moves ahead." - location: "market" - inventory: {} - - - name: "Nina" - personality: "Nurturing but exhausted caregiver; resenting how much others take." - location: "residential_area" - inventory: - bread: 1 - - - name: "Viktor" - personality: "Stern elder; pushes duty rotas and fair shares after surviving collapse once." - location: "residential_area" - inventory: - apple: 1 - - - name: "Jade" - personality: "Provocative wit; tests boundaries on purpose — boredom and anger masked as jokes." - location: "town_square" - inventory: {} - - - name: "Omar" - personality: "Calm mediator; translates fights into schedules, running on too little sleep." - location: "market" - inventory: - apple: 1 - - - name: "Rosa" - personality: "Meticulous scribe; keeps the official log and feels the pull of narrative power." - location: "residential_area" - inventory: {} diff --git a/antelab/config/identity.py b/antelab/config/identity.py deleted file mode 100644 index e5586a6..0000000 --- a/antelab/config/identity.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Stable run identity helpers for configuration-derived artifacts.""" - -from __future__ import annotations - -import hashlib -import json -from typing import NotRequired, TypedDict - -from antelab.config.loader import AnteLabConfig - - -class ConfigHashPayload(TypedDict): - """Hash input for run-defining configuration. - - The experiment seed is intentionally part of the hash for compatibility with - existing API run metadata: changing the deterministic random stream changes - run identity. Runtime server/history settings are excluded because they only - affect serving and persistence. - """ - - world: dict[str, object] - llm: dict[str, object] - lifecycle: dict[str, object] - pressure: dict[str, object] - experiment: dict[str, object] - scenario: dict[str, object] - long_run: dict[str, object] - agents: list[dict[str, object]] - schema_version: NotRequired[int] - - -def config_hash_payload(cfg: AnteLabConfig) -> ConfigHashPayload: - """Build the explicit stable payload used for config hashing.""" - return { - "schema_version": 2, - "world": { - "name": cfg.world.name, - "initial_locations": cfg.world.initial_locations, - "location_graph": cfg.world.location_graph, - "location_items": cfg.world.location_items, - "resource_zones": cfg.world.resource_zones, - "recipes": cfg.world.recipes, - "event_log_limit": cfg.world.event_log_limit, - }, - "llm": { - "mode": cfg.llm.mode, - "model": cfg.llm.model, - "temperature": cfg.llm.temperature, - "max_tokens": cfg.llm.max_tokens, - }, - "lifecycle": { - "age_tick_step": cfg.lifecycle.age_tick_step, - "life_stage_thresholds": cfg.lifecycle.life_stage_thresholds, - "vitality_loss_per_tick": cfg.lifecycle.vitality_loss_per_tick, - "vitality_rest_gain": cfg.lifecycle.vitality_rest_gain, - "stress_gain_per_tick": cfg.lifecycle.stress_gain_per_tick, - "stress_rest_reduction": cfg.lifecycle.stress_rest_reduction, - "disease_exposure_threshold": cfg.lifecycle.disease_exposure_threshold, - "disease_vitality_penalty": cfg.lifecycle.disease_vitality_penalty, - "disease_stress_penalty": cfg.lifecycle.disease_stress_penalty, - "disease_recovery_ticks": cfg.lifecycle.disease_recovery_ticks, - "disease_transmission_base_chance": cfg.lifecycle.disease_transmission_base_chance, - "disease_contact_weight": cfg.lifecycle.disease_contact_weight, - "disease_exposure_weight": cfg.lifecycle.disease_exposure_weight, - "disease_resilience_protection_weight": ( - cfg.lifecycle.disease_resilience_protection_weight - ), - "disease_need_vulnerability_weight": cfg.lifecycle.disease_need_vulnerability_weight, - "disease_recovery_base_chance": cfg.lifecycle.disease_recovery_base_chance, - "disease_recovery_resilience_weight": cfg.lifecycle.disease_recovery_resilience_weight, - "disease_recovery_rest_bonus": cfg.lifecycle.disease_recovery_rest_bonus, - "disease_exposure_decay_per_tick": cfg.lifecycle.disease_exposure_decay_per_tick, - "hunger_gain_per_tick": cfg.lifecycle.hunger_gain_per_tick, - "hunger_rest_reduction": cfg.lifecycle.hunger_rest_reduction, - "fatigue_gain_per_tick": cfg.lifecycle.fatigue_gain_per_tick, - "fatigue_rest_reduction": cfg.lifecycle.fatigue_rest_reduction, - "hunger_vitality_penalty_threshold": cfg.lifecycle.hunger_vitality_penalty_threshold, - "fatigue_stress_penalty_threshold": cfg.lifecycle.fatigue_stress_penalty_threshold, - "needs_penalty": cfg.lifecycle.needs_penalty, - "auto_eat_hunger_threshold": cfg.lifecycle.auto_eat_hunger_threshold, - "nourishment_gain_per_food": cfg.lifecycle.nourishment_gain_per_food, - "food_items": cfg.lifecycle.food_items, - "conception_base_chance": cfg.lifecycle.conception_base_chance, - "conception_vitality_weight": cfg.lifecycle.conception_vitality_weight, - "conception_stress_weight": cfg.lifecycle.conception_stress_weight, - "conception_hunger_weight": cfg.lifecycle.conception_hunger_weight, - "conception_infection_penalty": cfg.lifecycle.conception_infection_penalty, - "conception_trust_weight": cfg.lifecycle.conception_trust_weight, - "conception_obligation_weight": cfg.lifecycle.conception_obligation_weight, - "conception_min_vitality": cfg.lifecycle.conception_min_vitality, - "conception_max_stress": cfg.lifecycle.conception_max_stress, - "pregnancy_duration_min_ticks": cfg.lifecycle.pregnancy_duration_min_ticks, - "pregnancy_duration_max_ticks": cfg.lifecycle.pregnancy_duration_max_ticks, - "social_memory_max_entries": cfg.lifecycle.social_memory_max_entries, - }, - "pressure": { - "survival": {"enabled": cfg.pressure.survival.enabled}, - "resources": { - "enabled": cfg.pressure.resources.enabled, - "decay_every": cfg.pressure.resources.decay_every, - "storage_decay_multiplier": cfg.pressure.resources.storage_decay_multiplier, - "regeneration_every": cfg.pressure.resources.regeneration_every, - }, - "disease": {"enabled": cfg.pressure.disease.enabled}, - "environment": { - "enabled": cfg.pressure.environment.enabled, - "season_length_ticks": cfg.pressure.environment.season_length_ticks, - }, - }, - "experiment": { - "name": cfg.experiment.name, - "description": cfg.experiment.description, - "seed": cfg.experiment.seed, - "perception": cfg.experiment.perception, - "communication": cfg.experiment.communication, - "social_tracking": cfg.experiment.social_tracking, - "auto_eat": cfg.experiment.auto_eat, - "mortality": cfg.experiment.mortality, - "memory_size": cfg.experiment.memory_size, - }, - "scenario": { - "id": cfg.scenario.id, - "title": cfg.scenario.title, - "hypothesis": cfg.scenario.hypothesis, - "counter_hypothesis": cfg.scenario.counter_hypothesis, - "tags": cfg.scenario.tags, - }, - "long_run": { - "ticks": cfg.long_run.ticks, - "seed": cfg.long_run.seed, - "benchmark_agents": cfg.long_run.benchmark_agents, - "diagnostics_every": cfg.long_run.diagnostics_every, - "artifact_every": cfg.long_run.artifact_every, - }, - "agents": [ - { - "name": agent.name, - "personality": agent.personality, - "location": agent.location, - "inventory": agent.inventory, - } - for agent in cfg.agents - ], - } - - -def config_hash(cfg: AnteLabConfig) -> str: - """Return a stable SHA-256 hash for run-defining config fields.""" - serialized = json.dumps(config_hash_payload(cfg), sort_keys=True, ensure_ascii=True) - return hashlib.sha256(serialized.encode("utf-8")).hexdigest() diff --git a/antelab/config/loader.py b/antelab/config/loader.py deleted file mode 100644 index 27adb82..0000000 --- a/antelab/config/loader.py +++ /dev/null @@ -1,870 +0,0 @@ -"""Configuration loading: YAML file + environment variable overrides. - -Override chain (lowest to highest priority): - 1. Built-in defaults (dataclass defaults) - 2. Config file (default.yaml or ANTELAB_CONFIG_PATH) - 3. Environment variables (ANTELAB_* prefix) -""" - -from __future__ import annotations - -import os -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import yaml - -from antelab.llm.narrative import NarrativeConfig - -_DEFAULT_CONFIG_PATH = Path(__file__).parent / "default.yaml" - - -def _read_yaml_file(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as f: - loaded = yaml.safe_load(f) or {} - if not isinstance(loaded, dict): - raise ValueError(f"Config file must contain a mapping: {path}") - return loaded - - -def _deep_merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - merged: dict[str, Any] = dict(base) - replace_keys = { - "location_graph", - "location_items", - "resource_zones", - "recipes", - "demand_streams", - "candidate_pool", - "survival_gauntlet", - "shocks", - } - for key, value in override.items(): - if key in replace_keys: - merged[key] = value - continue - if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): - merged[key] = _deep_merge_dicts(merged[key], value) - else: - merged[key] = value - return merged - - -def _load_raw_config(path: Path, stack: set[Path] | None = None) -> dict[str, Any]: - chain = stack or set() - resolved = path.resolve() - if resolved in chain: - raise ValueError(f"Cyclic config extends detected at: {resolved}") - chain.add(resolved) - - if not resolved.exists(): - raise FileNotFoundError(f"Config file not found: {resolved}") - - raw = _read_yaml_file(resolved) - extends = raw.pop("extends", None) - if extends is None: - chain.remove(resolved) - return raw - - if isinstance(extends, str): - parent_refs = [extends] - elif isinstance(extends, list) and all(isinstance(item, str) for item in extends): - parent_refs = extends - else: - raise ValueError(f"Invalid 'extends' format in {resolved}. Expected string or string list.") - - merged_parent: dict[str, Any] = {} - for parent_ref in parent_refs: - parent_path = Path(parent_ref) - if not parent_path.is_absolute(): - parent_path = (resolved.parent / parent_path).resolve() - parent_raw = _load_raw_config(parent_path, chain) - merged_parent = _deep_merge_dicts(merged_parent, parent_raw) - - chain.remove(resolved) - return _deep_merge_dicts(merged_parent, raw) - - -@dataclass -class LLMConfig: - mode: str = "mock" - model: str = "gpt-4o-mini" - temperature: float = 0.7 - max_tokens: int = 512 - - -@dataclass -class LifecycleConfig: - age_tick_step: int = 1 - life_stage_thresholds: dict[str, int] = field( - default_factory=lambda: { - "juvenile": 20, - "adult": 60, - "elder": 360, - } - ) - vitality_loss_per_tick: int = 1 - vitality_rest_gain: int = 2 - stress_gain_per_tick: int = 1 - stress_rest_reduction: int = 2 - disease_exposure_threshold: int = 3 - disease_vitality_penalty: int = 2 - disease_stress_penalty: int = 1 - disease_recovery_ticks: int = 8 - disease_transmission_base_chance: float = 0.08 - disease_contact_weight: float = 0.22 - disease_exposure_weight: float = 0.1 - disease_resilience_protection_weight: float = 0.5 - disease_need_vulnerability_weight: float = 0.25 - disease_recovery_base_chance: float = 0.05 - disease_recovery_resilience_weight: float = 0.45 - disease_recovery_rest_bonus: float = 0.2 - disease_exposure_decay_per_tick: int = 1 - hunger_gain_per_tick: int = 1 - hunger_rest_reduction: int = 1 - fatigue_gain_per_tick: int = 1 - fatigue_rest_reduction: int = 3 - hunger_vitality_penalty_threshold: int = 70 - fatigue_stress_penalty_threshold: int = 70 - needs_penalty: int = 2 - auto_eat_hunger_threshold: int = 50 - nourishment_gain_per_food: int = 20 - food_items: list[str] = field(default_factory=lambda: ["bread", "apple"]) - conception_base_chance: float = 0.06 - conception_vitality_weight: float = 0.45 - conception_stress_weight: float = 0.35 - conception_hunger_weight: float = 0.25 - conception_infection_penalty: float = 0.4 - conception_trust_weight: float = 0.15 - conception_obligation_weight: float = 0.08 - conception_min_vitality: int = 55 - conception_max_stress: int = 60 - pregnancy_duration_min_ticks: int = 16 - pregnancy_duration_max_ticks: int = 32 - social_memory_max_entries: int = 64 - - -@dataclass -class SurvivalPressureConfig: - enabled: bool = True - - -@dataclass -class ResourcePressureConfig: - enabled: bool = True - decay_every: int = 0 - storage_decay_multiplier: float = 0.25 - regeneration_every: int = 0 - - -@dataclass -class DiseasePressureConfig: - enabled: bool = True - - -@dataclass -class EnvironmentPressureConfig: - enabled: bool = False - season_length_ticks: int = 500 - - -@dataclass -class PressureConfig: - survival: SurvivalPressureConfig = field(default_factory=SurvivalPressureConfig) - resources: ResourcePressureConfig = field(default_factory=ResourcePressureConfig) - disease: DiseasePressureConfig = field(default_factory=DiseasePressureConfig) - environment: EnvironmentPressureConfig = field(default_factory=EnvironmentPressureConfig) - - -@dataclass -class ServerConfig: - host: str = "127.0.0.1" - port: int = 8080 - log_level: str = "INFO" - - -@dataclass -class HistoryConfig: - path: str = str(Path(tempfile.gettempdir()) / "antelab" / "history.jsonl") - max_entries: int = 500 - compact_every: int = 100 - - -@dataclass -class WorldConfig: - name: str = "AnteLab World" - initial_locations: list[str] = field( - default_factory=lambda: ["town_square", "market", "residential_area"] - ) - location_graph: dict[str, list[str]] = field(default_factory=dict) - location_items: dict[str, dict[str, int]] = field(default_factory=dict) - resource_zones: dict[str, dict[str, dict[str, int]]] = field(default_factory=dict) - recipes: dict[str, dict[str, dict[str, int]]] = field(default_factory=dict) - event_log_limit: int = 2000 - - -def _normalize_location_graph( - initial_locations: list[str], - location_graph: dict[str, Any], -) -> dict[str, list[str]]: - if not location_graph: - # Backward-compatible fallback: fully connected graph. - return { - loc: [o for o in initial_locations if o != loc] - for loc in initial_locations - } - - normalized: dict[str, list[str]] = {} - known_locations = set(initial_locations) - for node, neighbors_raw in location_graph.items(): - if node not in known_locations: - raise ValueError(f"location_graph contains unknown node: {node}") - if not isinstance(neighbors_raw, list): - raise ValueError(f"location_graph[{node}] must be a list") - normalized_neighbors: list[str] = [] - for neighbor in neighbors_raw: - if neighbor not in known_locations: - raise ValueError(f"location_graph[{node}] references unknown neighbor: {neighbor}") - if neighbor not in normalized_neighbors: - normalized_neighbors.append(neighbor) - normalized[node] = normalized_neighbors - - for location in initial_locations: - normalized.setdefault(location, []) - - return normalized - - -def _normalize_recipes( - recipes_raw: dict[str, Any], -) -> dict[str, dict[str, dict[str, int]]]: - normalized: dict[str, dict[str, dict[str, int]]] = {} - for recipe_name, recipe_def in recipes_raw.items(): - if not isinstance(recipe_def, dict): - raise ValueError(f"Recipe '{recipe_name}' must be a mapping") - inputs_raw = recipe_def.get("inputs", {}) - outputs_raw = recipe_def.get("outputs", {}) - if not isinstance(inputs_raw, dict) or not isinstance(outputs_raw, dict): - raise ValueError(f"Recipe '{recipe_name}' must contain mapping inputs/outputs") - if not inputs_raw or not outputs_raw: - raise ValueError(f"Recipe '{recipe_name}' cannot have empty inputs/outputs") - inputs: dict[str, int] = {} - outputs: dict[str, int] = {} - for item, qty in inputs_raw.items(): - qty_int = int(qty) - if qty_int <= 0: - raise ValueError( - f"Recipe '{recipe_name}' has non-positive " - f"input quantity for '{item}'" - ) - inputs[str(item)] = qty_int - for item, qty in outputs_raw.items(): - qty_int = int(qty) - if qty_int <= 0: - raise ValueError( - f"Recipe '{recipe_name}' has non-positive " - f"output quantity for '{item}'" - ) - outputs[str(item)] = qty_int - normalized[recipe_name] = {"inputs": inputs, "outputs": outputs} - return normalized - - -def _normalize_resource_zones( - initial_locations: list[str], - zones_raw: dict[str, Any], -) -> dict[str, dict[str, dict[str, int]]]: - if not isinstance(zones_raw, dict): - raise ValueError("resource_zones must be a mapping") - normalized: dict[str, dict[str, dict[str, int]]] = {} - known = set(initial_locations) - for location, resources in zones_raw.items(): - if location not in known: - raise ValueError(f"resource_zones contains unknown location: {location}") - if not isinstance(resources, dict): - raise ValueError(f"resource_zones[{location}] must be a mapping") - normalized[location] = {} - for item, spec in resources.items(): - if not isinstance(spec, dict): - raise ValueError(f"resource_zones[{location}][{item}] must be a mapping") - quantity = _positive_int( - spec.get("quantity", 1), - f"resource_zones.{location}.{item}.quantity", - ) - every = _positive_int( - spec.get("every", 1), - f"resource_zones.{location}.{item}.every", - ) - normalized[location][str(item)] = {"quantity": quantity, "every": every} - return normalized - - -@dataclass -class AgentDef: - name: str = "" - personality: str = "" - location: str = "" - inventory: dict[str, int] = field(default_factory=dict) - - -@dataclass -class ExperimentConfig: - """Experiment axiom toggles for the civilization laboratory.""" - - name: str = "baseline" - description: str = "Default physics — control group" - seed: int = 42 - perception: str = "local" - communication: str = "colocated" - social_tracking: bool = True - auto_eat: bool = False - mortality: bool = True - memory_size: int = 50 - - -@dataclass -class ScenarioConfig: - id: str = "baseline" - title: str = "Baseline" - hypothesis: str = "" - counter_hypothesis: str = "" - tags: list[str] = field(default_factory=list) - - -@dataclass -class LongRunConfig: - ticks: int = 1000 - seed: int = 42 - benchmark_agents: int = 30 - diagnostics_every: int = 100 - artifact_every: int = 10 - - -@dataclass -class CompanyMarketConfig: - demand_difficulty_by_stage: dict[str, int] = field( - default_factory=lambda: { - "garage": 1, "workshop": 2, "formal": 3, "scale": 4, "mature": 5, - } - ) - max_open_demands: int = 5 - demand_generation_every: int = 3 - reward_base: dict[str, int] = field( - default_factory=lambda: { - "garage": 5, "workshop": 15, "formal": 40, "scale": 100, "mature": 500, - } - ) - reward_spread: float = 0.5 - deadline_ticks_base: dict[str, int] = field( - default_factory=lambda: { - "garage": 10, "workshop": 15, "formal": 20, "scale": 30, "mature": 50, - } - ) - shocks: dict[str, list[dict[str, Any]]] = field(default_factory=dict) - - -@dataclass -class CompanyPatternConfig: - window_ticks: int = 20 - threshold: int = 5 - categories: list[str] = field( - default_factory=lambda: ["DELIVERY", "CRAFTING", "COORDINATION", "DELEGATION", "PLANNING"] - ) - suggestion_expiry_ticks: int = 5 - - -@dataclass -class CompanyOrgConfig: - max_departments: int = 12 - auto_departments_from_suggestions: bool = True - min_members_for_team: int = 2 - - -@dataclass -class CompanyValuationConfig: - ipo_valuation_target: int = 10_000_000_000 - ipo_consecutive_profitable_ticks: int = 5 - ipo_min_team_size: int = 5 - ipo_min_departments: int = 1 - weights: dict[str, float] = field( - default_factory=lambda: { - "cumulative_revenue": 10.0, - "revenue_growth_rate": 100.0, - "team_size": 5.0, - "org_complexity": 2.0, - "demand_completion_rate": 50.0, - "cash_reserve": 2.0, - } - ) - growth_rate_window_ticks: int = 10 - - -@dataclass -class CompanySpaceConfig: - stages: list[str] = field( - default_factory=lambda: ["garage", "office", "floor", "campus"] - ) - capacity_by_stage: dict[str, int] = field( - default_factory=lambda: {"garage": 3, "office": 8, "floor": 20, "campus": 50} - ) - expansion_cost_by_stage: dict[str, int] = field( - default_factory=lambda: {"office": 50, "floor": 200, "campus": 1000} - ) - capacity_pressure_ticks: int = 3 - stress_per_overcapacity: int = 2 - - -@dataclass -class CompanyConfig: - enabled: bool = False - name: str = "" - stage: str = "founder" - cash: int = 0 - operating_cost_per_tick: int = 0 - demand_streams: list[dict[str, Any]] = field(default_factory=list) - candidate_pool: list[dict[str, Any]] = field(default_factory=list) - survival_gauntlet: dict[str, Any] = field(default_factory=dict) - market: CompanyMarketConfig = field(default_factory=CompanyMarketConfig) - pattern: CompanyPatternConfig = field(default_factory=CompanyPatternConfig) - org: CompanyOrgConfig = field(default_factory=CompanyOrgConfig) - valuation: CompanyValuationConfig = field(default_factory=CompanyValuationConfig) - space: CompanySpaceConfig = field(default_factory=CompanySpaceConfig) - - -@dataclass -class AnteLabConfig: - world: WorldConfig = field(default_factory=WorldConfig) - llm: LLMConfig = field(default_factory=LLMConfig) - lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig) - pressure: PressureConfig = field(default_factory=PressureConfig) - server: ServerConfig = field(default_factory=ServerConfig) - history: HistoryConfig = field(default_factory=HistoryConfig) - experiment: ExperimentConfig = field(default_factory=ExperimentConfig) - scenario: ScenarioConfig = field(default_factory=ScenarioConfig) - long_run: LongRunConfig = field(default_factory=LongRunConfig) - narrative: NarrativeConfig = field(default_factory=NarrativeConfig) - company: CompanyConfig = field(default_factory=CompanyConfig) - agents: list[AgentDef] = field(default_factory=list) - - -def _apply_env_overrides(config: AnteLabConfig) -> None: - """Override config fields with ANTELAB_* environment variables.""" - _env_map: list[tuple[str, Any, str, type]] = [ - ("ANTELAB_LLM_MODE", config.llm, "mode", str), - ("ANTELAB_LLM_MODEL", config.llm, "model", str), - ("ANTELAB_LLM_TEMPERATURE", config.llm, "temperature", float), - ("ANTELAB_LLM_MAX_TOKENS", config.llm, "max_tokens", int), - ("ANTELAB_SERVER_HOST", config.server, "host", str), - ("ANTELAB_SERVER_PORT", config.server, "port", int), - ("ANTELAB_LOG_LEVEL", config.server, "log_level", str), - ("ANTELAB_HISTORY_PATH", config.history, "path", str), - ("ANTELAB_HISTORY_MAX_ENTRIES", config.history, "max_entries", int), - ("ANTELAB_HISTORY_COMPACT_EVERY", config.history, "compact_every", int), - ] - for env_key, obj, attr, typ in _env_map: - val = os.environ.get(env_key) - if val is not None: - setattr(obj, attr, typ(val)) - - -def _build_world_config(raw: dict[str, Any]) -> WorldConfig: - initial_locations = raw.get("initial_locations", ["town_square", "market", "residential_area"]) - location_graph = _normalize_location_graph( - initial_locations=initial_locations, - location_graph=raw.get("location_graph", {}), - ) - recipes = _normalize_recipes(raw.get("recipes", {})) - resource_zones = _normalize_resource_zones( - initial_locations=initial_locations, - zones_raw=raw.get("resource_zones", {}), - ) - return WorldConfig( - name=raw.get("name", "AnteLab World"), - initial_locations=initial_locations, - location_graph=location_graph, - location_items={loc: dict(items) for loc, items in raw.get("location_items", {}).items()}, - resource_zones=resource_zones, - recipes=recipes, - event_log_limit=int(raw.get("event_log_limit", 2000)), - ) - - -def _build_scenario_config(raw: dict[str, Any]) -> ScenarioConfig: - return ScenarioConfig( - id=str(raw.get("id", "baseline")), - title=str(raw.get("title", "Baseline")), - hypothesis=str(raw.get("hypothesis", "")), - counter_hypothesis=str(raw.get("counter_hypothesis", "")), - tags=[str(tag) for tag in raw.get("tags", [])], - ) - - -def _positive_int(raw: Any, field_name: str) -> int: - value = int(raw) - if value <= 0: - raise ValueError(f"{field_name} must be positive") - return value - - -def _non_negative_int(raw: Any, field_name: str) -> int: - value = int(raw) - if value < 0: - raise ValueError(f"{field_name} must be non-negative") - return value - - -def _non_negative_float(raw: Any, field_name: str) -> float: - value = float(raw) - if value < 0: - raise ValueError(f"{field_name} must be non-negative") - return value - - -def _optional_mapping(raw: dict[str, Any], key: str, field_name: str) -> dict[str, Any]: - value = raw.get(key, {}) - if not isinstance(value, dict): - raise ValueError(f"{field_name} must be a mapping") - return value - - -def _build_pressure_config(raw: dict[str, Any]) -> PressureConfig: - survival = _optional_mapping(raw, "survival", "pressure.survival") - resources = _optional_mapping(raw, "resources", "pressure.resources") - disease = _optional_mapping(raw, "disease", "pressure.disease") - environment = _optional_mapping(raw, "environment", "pressure.environment") - return PressureConfig( - survival=SurvivalPressureConfig(enabled=bool(survival.get("enabled", True))), - resources=ResourcePressureConfig( - enabled=bool(resources.get("enabled", True)), - decay_every=_non_negative_int( - resources.get("decay_every", 0), "pressure.resources.decay_every" - ), - storage_decay_multiplier=_non_negative_float( - resources.get("storage_decay_multiplier", 0.25), - "pressure.resources.storage_decay_multiplier", - ), - regeneration_every=_non_negative_int( - resources.get("regeneration_every", 0), - "pressure.resources.regeneration_every", - ), - ), - disease=DiseasePressureConfig(enabled=bool(disease.get("enabled", True))), - environment=EnvironmentPressureConfig( - enabled=bool(environment.get("enabled", False)), - season_length_ticks=_positive_int( - environment.get("season_length_ticks", 500), - "pressure.environment.season_length_ticks", - ), - ), - ) - - -def _build_long_run_config(raw: dict[str, Any]) -> LongRunConfig: - return LongRunConfig( - ticks=_positive_int(raw.get("ticks", 1000), "long_run.ticks"), - seed=int(raw.get("seed", 42)), - benchmark_agents=_positive_int( - raw.get("benchmark_agents", 30), "long_run.benchmark_agents" - ), - diagnostics_every=_positive_int( - raw.get("diagnostics_every", 100), "long_run.diagnostics_every" - ), - artifact_every=_positive_int(raw.get("artifact_every", 10), "long_run.artifact_every"), - ) - - -def _build_company_market_config(raw: dict[str, Any]) -> CompanyMarketConfig: - demand_diff = raw.get("demand_difficulty_by_stage", {}) - reward_base = raw.get("reward_base", {}) - deadline_base = raw.get("deadline_ticks_base", {}) - shocks_raw = raw.get("shocks", {}) - shocks: dict[str, list[dict[str, Any]]] = {} - for stage, shock_list in shocks_raw.items(): - if not isinstance(shock_list, list): - raise ValueError(f"company.market.shocks.{stage} must be a list") - shocks[str(stage)] = [dict(s) for s in shock_list] - return CompanyMarketConfig( - demand_difficulty_by_stage={str(k): int(v) for k, v in demand_diff.items()}, - max_open_demands=_non_negative_int( - raw.get("max_open_demands", 5), "company.market.max_open_demands", - ), - demand_generation_every=_positive_int( - raw.get("demand_generation_every", 3), "company.market.demand_generation_every", - ), - reward_base={str(k): _non_negative_int(v, f"company.market.reward_base.{k}") - for k, v in reward_base.items()}, - reward_spread=_non_negative_float( - raw.get("reward_spread", 0.5), "company.market.reward_spread", - ), - deadline_ticks_base={str(k): _positive_int(v, f"company.market.deadline_ticks_base.{k}") - for k, v in deadline_base.items()}, - shocks=shocks, - ) - - -def _build_company_pattern_config(raw: dict[str, Any]) -> CompanyPatternConfig: - return CompanyPatternConfig( - window_ticks=_positive_int( - raw.get("window_ticks", 20), "company.pattern.window_ticks", - ), - threshold=_positive_int( - raw.get("threshold", 5), "company.pattern.threshold", - ), - categories=[str(c) for c in raw.get("categories", CompanyPatternConfig().categories)], - suggestion_expiry_ticks=_positive_int( - raw.get("suggestion_expiry_ticks", 5), - "company.pattern.suggestion_expiry_ticks", - ), - ) - - -def _build_company_org_config(raw: dict[str, Any]) -> CompanyOrgConfig: - return CompanyOrgConfig( - max_departments=_positive_int( - raw.get("max_departments", 12), "company.org.max_departments", - ), - auto_departments_from_suggestions=bool( - raw.get("auto_departments_from_suggestions", True) - ), - min_members_for_team=_positive_int( - raw.get("min_members_for_team", 2), "company.org.min_members_for_team", - ), - ) - - -def _build_company_valuation_config(raw: dict[str, Any]) -> CompanyValuationConfig: - weights_raw = raw.get("weights", {}) - return CompanyValuationConfig( - ipo_valuation_target=_non_negative_int( - raw.get("ipo_valuation_target", 10_000_000_000), - "company.valuation.ipo_valuation_target", - ), - ipo_consecutive_profitable_ticks=_positive_int( - raw.get("ipo_consecutive_profitable_ticks", 5), - "company.valuation.ipo_consecutive_profitable_ticks", - ), - ipo_min_team_size=_positive_int( - raw.get("ipo_min_team_size", 5), "company.valuation.ipo_min_team_size", - ), - ipo_min_departments=_positive_int( - raw.get("ipo_min_departments", 1), "company.valuation.ipo_min_departments", - ), - weights={str(k): _non_negative_float(v, f"company.valuation.weights.{k}") - for k, v in weights_raw.items()}, - growth_rate_window_ticks=_positive_int( - raw.get("growth_rate_window_ticks", 10), - "company.valuation.growth_rate_window_ticks", - ), - ) - - -def _build_company_space_config(raw: dict[str, Any]) -> CompanySpaceConfig: - cap_raw = raw.get("capacity_by_stage", {}) - cost_raw = raw.get("expansion_cost_by_stage", {}) - return CompanySpaceConfig( - stages=[str(s) for s in raw.get("stages", CompanySpaceConfig().stages)], - capacity_by_stage={ - str(k): _positive_int(v, f"company.space.capacity_by_stage.{k}") - for k, v in cap_raw.items() - }, - expansion_cost_by_stage={ - str(k): _non_negative_int(v, f"company.space.expansion_cost_by_stage.{k}") - for k, v in cost_raw.items() - }, - capacity_pressure_ticks=_positive_int( - raw.get("capacity_pressure_ticks", 3), "company.space.capacity_pressure_ticks", - ), - stress_per_overcapacity=_non_negative_int( - raw.get("stress_per_overcapacity", 2), "company.space.stress_per_overcapacity", - ), - ) - - -def _build_company_config(raw: dict[str, Any]) -> CompanyConfig: - demand_streams_raw = raw.get("demand_streams", []) - if not isinstance(demand_streams_raw, list): - raise ValueError("company.demand_streams must be a list") - candidate_pool_raw = raw.get("candidate_pool", []) - if not isinstance(candidate_pool_raw, list): - raise ValueError("company.candidate_pool must be a list") - survival_gauntlet_raw = raw.get("survival_gauntlet", {}) - if not isinstance(survival_gauntlet_raw, dict): - raise ValueError("company.survival_gauntlet must be a mapping") - return CompanyConfig( - enabled=bool(raw.get("enabled", False)), - name=str(raw.get("name", "")), - stage=str(raw.get("stage", "founder")), - cash=_non_negative_int(raw.get("cash", 0), "company.cash"), - operating_cost_per_tick=_non_negative_int( - raw.get("operating_cost_per_tick", 0), - "company.operating_cost_per_tick", - ), - demand_streams=[dict(item) for item in demand_streams_raw], - candidate_pool=[dict(item) for item in candidate_pool_raw], - survival_gauntlet=dict(survival_gauntlet_raw), - market=_build_company_market_config(raw.get("market", {})), - pattern=_build_company_pattern_config(raw.get("pattern", {})), - org=_build_company_org_config(raw.get("org", {})), - valuation=_build_company_valuation_config(raw.get("valuation", {})), - space=_build_company_space_config(raw.get("space", {})), - ) - - -def _build_agent_defs(raw: list[dict[str, Any]]) -> list[AgentDef]: - return [ - AgentDef( - name=d.get("name", ""), - personality=d.get("personality", ""), - location=d.get("location", ""), - inventory=dict(d.get("inventory", {}) or {}), - ) - for d in raw - ] - - -def load_config(path: Path | None = None) -> AnteLabConfig: - """Load configuration from YAML + apply environment variable overrides.""" - config_path = path - if config_path is None: - env_path = os.environ.get("ANTELAB_CONFIG_PATH") - config_path = Path(env_path) if env_path else _DEFAULT_CONFIG_PATH - - raw: dict[str, Any] = {} - if config_path.exists(): - raw = _load_raw_config(config_path) - - raw_llm = raw.get("llm", {}) - raw_lifecycle = raw.get("lifecycle", {}) - raw_server = raw.get("server", {}) - raw_history = raw.get("history", {}) - raw_world = raw.get("world", {}) - raw_experiment = raw.get("experiment", {}) - raw_axioms = raw_experiment.get("axioms", {}) - raw_scenario = raw.get("scenario", {}) - raw_long_run = raw.get("long_run", {}) - raw_pressure = raw.get("pressure", {}) - raw_narrative = raw.get("narrative", {}) - raw_company = raw.get("company", {}) - raw_agents = raw.get("agents", []) - - config = AnteLabConfig( - world=_build_world_config(raw_world), - llm=LLMConfig( - mode=raw_llm.get("mode", "mock"), - model=raw_llm.get("model", "gpt-4o-mini"), - temperature=float(raw_llm.get("temperature", 0.7)), - max_tokens=int(raw_llm.get("max_tokens", 512)), - ), - lifecycle=LifecycleConfig( - age_tick_step=int(raw_lifecycle.get("age_tick_step", 1)), - life_stage_thresholds=dict( - raw_lifecycle.get( - "life_stage_thresholds", - LifecycleConfig().life_stage_thresholds, - ) - ), - vitality_loss_per_tick=int(raw_lifecycle.get("vitality_loss_per_tick", 1)), - vitality_rest_gain=int(raw_lifecycle.get("vitality_rest_gain", 2)), - stress_gain_per_tick=int(raw_lifecycle.get("stress_gain_per_tick", 1)), - stress_rest_reduction=int(raw_lifecycle.get("stress_rest_reduction", 2)), - disease_exposure_threshold=int(raw_lifecycle.get("disease_exposure_threshold", 3)), - disease_vitality_penalty=int(raw_lifecycle.get("disease_vitality_penalty", 2)), - disease_stress_penalty=int(raw_lifecycle.get("disease_stress_penalty", 1)), - disease_recovery_ticks=int(raw_lifecycle.get("disease_recovery_ticks", 8)), - disease_transmission_base_chance=float( - raw_lifecycle.get("disease_transmission_base_chance", 0.08) - ), - disease_contact_weight=float( - raw_lifecycle.get("disease_contact_weight", 0.22) - ), - disease_exposure_weight=float( - raw_lifecycle.get("disease_exposure_weight", 0.1) - ), - disease_resilience_protection_weight=float( - raw_lifecycle.get("disease_resilience_protection_weight", 0.5) - ), - disease_need_vulnerability_weight=float( - raw_lifecycle.get("disease_need_vulnerability_weight", 0.25) - ), - disease_recovery_base_chance=float( - raw_lifecycle.get("disease_recovery_base_chance", 0.05) - ), - disease_recovery_resilience_weight=float( - raw_lifecycle.get("disease_recovery_resilience_weight", 0.45) - ), - disease_recovery_rest_bonus=float( - raw_lifecycle.get("disease_recovery_rest_bonus", 0.2) - ), - disease_exposure_decay_per_tick=int( - raw_lifecycle.get("disease_exposure_decay_per_tick", 1) - ), - hunger_gain_per_tick=int(raw_lifecycle.get("hunger_gain_per_tick", 1)), - hunger_rest_reduction=int(raw_lifecycle.get("hunger_rest_reduction", 1)), - fatigue_gain_per_tick=int(raw_lifecycle.get("fatigue_gain_per_tick", 1)), - fatigue_rest_reduction=int(raw_lifecycle.get("fatigue_rest_reduction", 3)), - hunger_vitality_penalty_threshold=int( - raw_lifecycle.get("hunger_vitality_penalty_threshold", 70) - ), - fatigue_stress_penalty_threshold=int( - raw_lifecycle.get("fatigue_stress_penalty_threshold", 70) - ), - needs_penalty=int(raw_lifecycle.get("needs_penalty", 2)), - auto_eat_hunger_threshold=int(raw_lifecycle.get("auto_eat_hunger_threshold", 50)), - nourishment_gain_per_food=int(raw_lifecycle.get("nourishment_gain_per_food", 20)), - food_items=list(raw_lifecycle.get("food_items", ["bread", "apple"])), - conception_base_chance=float(raw_lifecycle.get("conception_base_chance", 0.06)), - conception_vitality_weight=float(raw_lifecycle.get("conception_vitality_weight", 0.45)), - conception_stress_weight=float(raw_lifecycle.get("conception_stress_weight", 0.35)), - conception_hunger_weight=float(raw_lifecycle.get("conception_hunger_weight", 0.25)), - conception_infection_penalty=float( - raw_lifecycle.get("conception_infection_penalty", 0.4) - ), - conception_trust_weight=float(raw_lifecycle.get("conception_trust_weight", 0.15)), - conception_obligation_weight=float( - raw_lifecycle.get("conception_obligation_weight", 0.08) - ), - conception_min_vitality=int(raw_lifecycle.get("conception_min_vitality", 55)), - conception_max_stress=int(raw_lifecycle.get("conception_max_stress", 60)), - pregnancy_duration_min_ticks=int(raw_lifecycle.get("pregnancy_duration_min_ticks", 16)), - pregnancy_duration_max_ticks=int(raw_lifecycle.get("pregnancy_duration_max_ticks", 32)), - social_memory_max_entries=int(raw_lifecycle.get("social_memory_max_entries", 64)), - ), - pressure=_build_pressure_config(raw_pressure), - server=ServerConfig( - host=raw_server.get("host", "127.0.0.1"), - port=int(raw_server.get("port", 8080)), - log_level=raw_server.get("log_level", "INFO"), - ), - history=HistoryConfig( - path=str(raw_history.get("path") or HistoryConfig().path), - max_entries=int(raw_history.get("max_entries", 500)), - compact_every=int(raw_history.get("compact_every", 100)), - ), - experiment=ExperimentConfig( - name=raw_experiment.get("name", "baseline"), - description=raw_experiment.get("description", "Default physics — control group"), - seed=int(raw_experiment.get("seed", 42)), - perception=raw_axioms.get("perception", "local"), - communication=raw_axioms.get("communication", "colocated"), - social_tracking=bool(raw_axioms.get("social_tracking", True)), - auto_eat=bool(raw_axioms.get("auto_eat", False)), - mortality=bool(raw_axioms.get("mortality", True)), - memory_size=int(raw_axioms.get("memory_size", 50)), - ), - scenario=_build_scenario_config(raw_scenario), - long_run=_build_long_run_config(raw_long_run), - narrative=NarrativeConfig( - enabled=bool(raw_narrative.get("enabled", True)), - model=str(raw_narrative.get("model", "inherit")), - max_tokens=int(raw_narrative.get("max_tokens", 256)), - temperature=float(raw_narrative.get("temperature", 0.7)), - generate_every_ticks=int(raw_narrative.get("generate_every_ticks", 1)), - ), - company=_build_company_config(raw_company), - agents=_build_agent_defs(raw_agents), - ) - - _apply_env_overrides(config) - return config diff --git a/antelab/engine/__init__.py b/antelab/engine/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/antelab/engine/agent.py b/antelab/engine/agent.py deleted file mode 100644 index 8f3dca5..0000000 --- a/antelab/engine/agent.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Agent: the autonomous entity that perceives, decides, and acts. - -Agents express free-form intents (Constitution Art. IV). -There is no predefined action menu. -""" - -from __future__ import annotations - -import copy -import json -import uuid -from dataclasses import dataclass, field -from typing import Any - -from antelab.engine.types import Action, ActionResult, Perception -from antelab.engine.world import World -from antelab.llm.client import LLMClient - -_DECISION_PROMPT = """\ -You are {name}, living in a small community. -Your personality: {personality} - -Current situation: -- Location: {location} -- Nearby people: {nearby} -- Items you carry: {inventory} -- Items nearby: {nearby_items} -- Your condition: {self_status} -- Environment: {environment} -- Local resources: {local_resources} -- Local features: {local_features} -- Recent events: -{events} -- Tick: {tick} -{company_section}{recipes_section} -Decide what to do next. You can attempt ANYTHING — speak, move, pick \ -something up, give an item to someone, examine your surroundings, rest, \ -or any other action you can imagine. The world will determine whether it \ -is physically possible. - -Respond with valid JSON: -{{ - "verb": "", - "parameters": {{ }}, - "reasoning": "" -}} - -JSON response:""" - - -def _format_counts(values: dict[str, int]) -> str: - return ", ".join( - f"{name}x{qty}" for name, qty in sorted(values.items()) if qty > 0 - ) or "nothing" - - -def _format_mapping(values: dict[str, Any]) -> str: - return ", ".join(f"{key}={value}" for key, value in sorted(values.items())) or "unknown" - - -def _local_recent_events(world: World, location: str, n: int) -> list[str]: - if world.axioms.perception == "global": - return world.get_recent_events(n) - return world.get_recent_events(n, location=location) - - -@dataclass -class Memory: - """Simple memory buffer storing recent events (Constitution Art. III).""" - - events: list[str] = field(default_factory=list) - max_size: int = 50 - - def add(self, event: str) -> None: - self.events.append(event) - if len(self.events) > self.max_size: - self.events = self.events[-self.max_size :] - - def recent(self, n: int = 10) -> list[str]: - return self.events[-n:] - - -@dataclass -class Agent: - """An autonomous agent in the simulation.""" - - id: str - name: str - personality: str - llm: LLMClient - memory: Memory = field(default_factory=Memory) - - @staticmethod - def create( - name: str, - personality: str, - llm: LLMClient, - memory_size: int = 50, - ) -> Agent: - return Agent( - id=str(uuid.uuid4())[:8], - name=name, - personality=personality, - llm=llm, - memory=Memory(max_size=memory_size), - ) - - def perceive(self, world: World) -> Perception: - """Observe the world from this agent's perspective (local only).""" - agent_state = world.agents[self.id] - location = agent_state.location - - company_context: dict[str, Any] = {} - if hasattr(world, "company") and world.company and world.company.enabled: - c = world.company - company_context = { - "name": c.name, - "stage": c.stage, - "cash": c.cash, - "burn_rate": c.operating_cost_per_tick, - "open_demands": [ - { - "id": d.request_id, - "description": d.description, - "required_item": d.required_item, - "reward": d.reward, - "deadline_tick": d.deadline_tick, - } - for d in c.demand_streams - if d.status == "open" - ], - "available_candidates": [ - { - "id": cand.candidate_id, - "name": cand.name, - "joining_cost": cand.joining_cost, - "role_claims": cand.role_claims, - } - for cand in c.candidate_pool - if cand.status == "available" - ], - "team_members": [ - { - "name": ws.name, - "role_claims": ws.role_claims, - } - for ws in world.agents.values() - if ws.alive and ws.role_claims - ], - # Agent-visible suggestions targeted at this agent. - "pending_suggestions": [ - s for s in c.pending_suggestions - if s.get("target_agent_id") == self.id - ], - # Agent-visible organization state. - "organization": c.org.to_summary(), - # Agent-visible office space (excludes valuation). - "office": ( - world.office_space.to_dict() - if world.office_space else {"stage": "garage", "capacity": 3} - ), - } - - recipes_known: dict[str, Any] = {} - if hasattr(world, "recipes") and world.recipes: - recipes_known = { - name: {"inputs": r.inputs, "outputs": r.outputs} - for name, r in world.recipes.items() - } - - return Perception( - tick=world.tick, - location=location, - nearby_agents=world.get_nearby_agents(self.id), - nearby_items=world.get_nearby_items(self.id), - recent_events=_local_recent_events(world, location, 5), - inventory=dict(agent_state.inventory), - self_status={ - "life_stage": agent_state.life_stage, - "vitality": agent_state.vitality, - "stress": agent_state.stress, - "hunger": agent_state.hunger, - "fatigue": agent_state.fatigue, - "contagion_status": agent_state.contagion_profile.get("status", "unknown"), - }, - environment=copy.deepcopy(world.environment), - local_resources=dict(world.location_items.get(location, {})), - local_features=dict(world.location_features.get(location, {})), - company_context=company_context, - recipes_known=recipes_known, - ) - - async def decide(self, perception: Perception) -> Action: - """Use the LLM to express a free-form intent.""" - company_section = "" - if perception.company_context: - cc = perception.company_context - lines = [f"\nCompany: {cc['name']} (stage: {cc['stage']})"] - lines.append(f" Cash: {cc['cash']} (burn: {cc['burn_rate']}/tick)") - if cc["open_demands"]: - for d in cc["open_demands"]: - lines.append( - f" Demand: {d['description']} — needs {d['required_item']}, " - f"reward {d['reward']}, deadline tick {d['deadline_tick']}" - ) - else: - lines.append(" No open demands.") - if cc["available_candidates"]: - for cand in cc["available_candidates"]: - lines.append( - f" Candidate: {cand['name']} (cost: {cand['joining_cost']}, " - f"roles: {', '.join(cand['role_claims'])})" - ) - if cc["team_members"]: - members = ", ".join( - f"{m['name']}[{','.join(m['role_claims'])}]" for m in cc["team_members"] - ) - lines.append(f" Team: {members}") - # Pending suggestions for this agent. - suggestions = cc.get("pending_suggestions", []) - if suggestions: - lines.append(" Pending suggestions:") - for sug in suggestions: - lines.append( - f" [{sug['suggestion_id']}] {sug['message']}" - ) - lines.append( - " To accept: accept_suggestion(suggestion_id). " - "To modify: modify_suggestion(suggestion_id, " - "modifications={name: '...'}). To ignore: do nothing." - ) - # Organization state. - org = cc.get("organization", {}) - if org.get("departments"): - dept_list = ", ".join( - f"{d['name']}(lead:{d['lead_agent_id']}, {len(d['member_agent_ids'])} members)" - for d in org["departments"] - ) - lines.append(f" Departments: {dept_list}") - if org.get("roles"): - role_list = ", ".join( - f"{r['name']}({r['holder_agent_id']})" for r in org["roles"] - ) - lines.append(f" Roles: {role_list}") - # Office space. - office = cc.get("office", {}) - if office: - lines.append( - f" Office: {office.get('stage', 'garage')} " - f"(capacity: {office.get('capacity', 0)})" - ) - lines.append( - " You can: craft (recipe), deliver (item, demand), " - "recruit (candidate_id), interview (candidate_id), " - "write_artifact (title, body), " - "read_artifact (artifact_id), update_artifact (artifact_id, body), " - "accept_suggestion (suggestion_id), " - "modify_suggestion (suggestion_id, modifications), " - "create_role (role_name, department_id?), " - "form_team (team_name, member_ids)." - ) - company_section = "\n".join(lines) + "\n" - - recipes_section = "" - if perception.recipes_known: - lines = ["\nKnown recipes:"] - for name, r in perception.recipes_known.items(): - inputs = ", ".join(f"{k}x{v}" for k, v in r["inputs"].items()) - outputs = ", ".join(f"{k}x{v}" for k, v in r["outputs"].items()) - lines.append(f" {name}: {inputs} -> {outputs}") - recipes_section = "\n".join(lines) + "\n" - - prompt = _DECISION_PROMPT.format( - name=self.name, - personality=self.personality, - location=perception.location, - nearby=", ".join(perception.nearby_agents) or "nobody", - inventory=( - ", ".join( - f"{item}x{qty}" - for item, qty in sorted(perception.inventory.items()) - if qty > 0 - ) or "nothing" - ), - nearby_items=", ".join(perception.nearby_items) or "nothing", - self_status=", ".join( - f"{key}={value}" for key, value in perception.self_status.items() - ) or "unknown", - environment=_format_mapping(perception.environment), - local_resources=_format_counts(perception.local_resources), - local_features=_format_counts(perception.local_features), - events="\n".join(f" - {e}" for e in perception.recent_events) or " - nothing yet", - tick=perception.tick, - company_section=company_section, - recipes_section=recipes_section, - ) - - try: - response = await self.llm.complete_json(prompt) - return Action( - agent_id=self.id, - verb=response.get("verb", "rest"), - parameters=response.get("parameters", {}), - reasoning=response.get("reasoning", ""), - ) - except (json.JSONDecodeError, KeyError): - return Action( - agent_id=self.id, - verb="rest", - reasoning="Failed to parse LLM response", - ) - - def remember(self, result: ActionResult) -> None: - """Store the outcome of an action in memory.""" - self.memory.add(result.description) - - def restore_private_state( - self, - *, - personality: str | None = None, - memory_events: list[str] | None = None, - ) -> None: - """Restore narrative state from a snapshot (world state is loaded separately).""" - if personality is not None: - self.personality = personality - if memory_events is not None: - self.memory.events = memory_events[-self.memory.max_size :] diff --git a/antelab/engine/market.py b/antelab/engine/market.py deleted file mode 100644 index 7551947..0000000 --- a/antelab/engine/market.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Market system: demand generation, escalation, shocks, and revenue tracking.""" - -from __future__ import annotations - -import random -import uuid -from dataclasses import dataclass, field -from typing import Any - -from antelab.engine.types import CompanyDemand - -# Item pools per stage — used by generate_demands() to create stage-appropriate -# required deliverables. Each tuple is (item_name, description_template). -_DEMAND_ITEM_POOL: dict[str, list[tuple[str, str]]] = { - "garage": [ - ("prototype", "Ship a working {item}"), - ("report", "Deliver a {item} summarizing progress"), - ], - "workshop": [ - ("prototype", "Ship an improved {item}"), - ("report", "Deliver a {item} on delivery metrics"), - ("design_spec", "Write a {item} for the next feature"), - ], - "formal": [ - ("prototype", "Ship a polished {item}"), - ("report", "Deliver a compliance {item}"), - ("design_spec", "Produce a {item} for enterprise review"), - ("integration", "Complete {item} with partner system"), - ], - "scale": [ - ("prototype", "Ship a scalable {item}"), - ("report", "Deliver a quarterly {item}"), - ("design_spec", "Create a {item} for new market segment"), - ("integration", "Complete multi-tenant {item}"), - ("bulk_order", "Fulfill {item} for enterprise client"), - ], - "mature": [ - ("prototype", "Ship a strategic {item}"), - ("report", "Deliver an annual {item}"), - ("design_spec", "Produce a {item} for acquisition target"), - ("integration", "Complete global {item} rollout"), - ("bulk_order", "Fulfill {item} for multi-year contract"), - ], -} - - -@dataclass -class MarketState: - """Runtime market state for company emergence scenarios.""" - - active_demands: list[CompanyDemand] = field(default_factory=list) - shock_pool: dict[str, list[dict[str, Any]]] = field(default_factory=dict) - demand_difficulty_by_stage: dict[str, int] = field( - default_factory=lambda: { - "garage": 1, "workshop": 2, "formal": 3, "scale": 4, "mature": 5, - } - ) - max_open_demands: int = 5 - demand_generation_every: int = 3 - reward_base: dict[str, int] = field( - default_factory=lambda: { - "garage": 5, "workshop": 15, "formal": 40, "scale": 100, "mature": 500, - } - ) - reward_spread: float = 0.5 - deadline_ticks_base: dict[str, int] = field( - default_factory=lambda: { - "garage": 10, "workshop": 15, "formal": 20, "scale": 30, "mature": 50, - } - ) - ticks_since_last_generation: int = 0 - total_revenue_earned: int = 0 - demand_history: list[dict[str, Any]] = field(default_factory=list) - active_shocks: list[dict[str, Any]] = field(default_factory=list) - stage_transitioned: bool = False - - def count_open_demands(self) -> int: - return sum(1 for d in self.active_demands if d.status == "open") - - def generate_demands(self, stage: str, current_tick: int) -> list[CompanyDemand]: - """Create new open demands appropriate for the company stage.""" - open_count = self.count_open_demands() - slots = max(0, self.max_open_demands - open_count) - if slots <= 0: - return [] - - # Generate 1-2 new demands per generation cycle (capped by slots). - to_generate = min(random.randint(1, 2), slots) - difficulty = self.demand_difficulty_by_stage.get(stage, 1) - base_reward = self.reward_base.get(stage, 5) - base_deadline = self.deadline_ticks_base.get(stage, 10) - item_pool = _DEMAND_ITEM_POOL.get(stage, _DEMAND_ITEM_POOL["garage"]) - - new_demands: list[CompanyDemand] = [] - for _ in range(to_generate): - item_name, desc_template = random.choice(item_pool) - reward = int( - base_reward - * (1 + random.uniform(-self.reward_spread, self.reward_spread)) - ) - reward = max(1, reward * difficulty) - deadline = current_tick + base_deadline + random.randint(-2, 4) - deadline = max(current_tick + 2, deadline) - - demand = CompanyDemand( - request_id=f"demand-{current_tick}-{uuid.uuid4().hex[:6]}", - description=desc_template.format(item=item_name), - required_item=item_name, - reward=reward, - deadline_tick=deadline, - status="open", - ) - new_demands.append(demand) - - self.active_demands.extend(new_demands) - self.ticks_since_last_generation = 0 - return new_demands - - def escalate_demands(self, new_stage: str, _current_tick: int) -> int: - """Upgrade open demands to match a new company stage. Returns count of escalated demands.""" - new_reward_base = self.reward_base.get(new_stage, self.reward_base.get("garage", 5)) - escalated = 0 - for demand in self.active_demands: - if demand.status == "open": - ratio = max(1, new_reward_base / max(1, demand.reward)) - demand.reward = max(demand.reward, int(demand.reward * ratio)) - escalated += 1 - self.stage_transitioned = True - return escalated - - def apply_market_shock( - self, stage: str, current_tick: int, rng: random.Random | None = None - ) -> dict[str, Any] | None: - """Randomly draw and apply a market shock. Returns the shock dict or None.""" - rand = rng or random - stage_shocks = list(self.shock_pool.get(stage, [])) - global_shocks = list(self.shock_pool.get("global", [])) - all_shocks = stage_shocks + global_shocks - if not all_shocks: - return None - - # Each shock has a "weight" field; draw one. - total_weight = sum(float(s.get("weight", 0.1)) for s in all_shocks) - roll = rand.uniform(0, total_weight) - cumulative = 0.0 - chosen: dict[str, Any] | None = None - for shock in all_shocks: - cumulative += float(shock.get("weight", 0.1)) - if roll <= cumulative: - chosen = dict(shock) - break - if chosen is None: - return None - - shock = chosen - shock["applied_tick"] = current_tick - kind = shock.get("kind", "") - - if kind == "demand_shift": - # Replace open demands with new ones at a potentially different stage. - self.active_demands = [d for d in self.active_demands if d.status != "open"] - # Use the current stage directly for replacement demands. - target_stage = shock.get("target_stage", stage) - self.generate_demands(target_stage, current_tick) - - elif kind == "economic_downturn": - # Reduce rewards on all open demands. - factor = float(shock.get("reward_factor", 0.5)) - for demand in self.active_demands: - if demand.status == "open": - demand.reward = max(1, int(demand.reward * factor)) - downtime_ticks = int(shock.get("duration_ticks", 5)) - shock["expires_tick"] = current_tick + downtime_ticks - - elif kind == "competitor_entry": - # Shorten deadlines on open demands (urgency). - reduction = int(shock.get("deadline_reduction", 5)) - for demand in self.active_demands: - if demand.status == "open": - demand.deadline_tick = max(current_tick + 1, demand.deadline_tick - reduction) - - elif kind == "boom": - # Double rewards on open demands. - factor = float(shock.get("reward_factor", 2.0)) - for demand in self.active_demands: - if demand.status == "open": - demand.reward = int(demand.reward * factor) - boom_ticks = int(shock.get("duration_ticks", 5)) - shock["expires_tick"] = current_tick + boom_ticks - - self.active_shocks.append(shock) - return shock - - def record_delivery(self, reward: int) -> None: - """Record a successful delivery.""" - self.total_revenue_earned += reward - self.demand_history.append({"reward": reward}) - - def mark_expired_shocks(self, current_tick: int) -> int: - """Remove shocks past their expiration. Returns count of expired shocks.""" - before = len(self.active_shocks) - self.active_shocks = [ - s for s in self.active_shocks - if s.get("expires_tick", 0) > current_tick - ] - return before - len(self.active_shocks) diff --git a/antelab/engine/measurement.py b/antelab/engine/measurement.py deleted file mode 100644 index e91117d..0000000 --- a/antelab/engine/measurement.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Measurement framework for civilization experiments. - -Records per-tick metrics without affecting simulation behavior. -Designed for comparing experiment runs against the baseline. -""" - -from __future__ import annotations - -import copy -from collections import Counter -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -from antelab.engine.types import Action, ActionResult - -if TYPE_CHECKING: - from antelab.engine.world import AgentState, World - - -@dataclass -class TickMeasurement: - """Metrics collected during a single tick.""" - - tick: int - action_verbs: Counter[str] = field(default_factory=Counter) - resource_transfers: list[dict[str, Any]] = field(default_factory=list) - agent_locations: dict[str, str] = field(default_factory=dict) - communication_count: int = 0 - cooperation_events: int = 0 - defection_events: int = 0 - failed_actions: int = 0 - total_actions: int = 0 - alive_agents: int = 0 - dead_agents: int = 0 - births: int = 0 - deaths: int = 0 - resource_total: int = 0 - disease_infected: int = 0 - season: str = "" - - -class ExperimentObserver: - """Collects structured measurements across ticks for experiment analysis.""" - - #: Drop unmatched give markers after this many ticks to cap memory / stale keys. - _PENDING_GIVE_MAX_AGE_TICKS = 50 - #: Retain enough ticks for target long-run experiments while bounding memory. - MAX_RETAINED_TICKS = 10_000 - - def __init__(self, max_retained_ticks: int | None = None) -> None: - self.ticks: list[TickMeasurement] = [] - self.action_trace: list[dict[str, Any]] = [] - self._current: TickMeasurement | None = None - self._pending_gives: dict[str, dict[str, Any]] = {} - self._max_retained_ticks = max(1, max_retained_ticks or self.MAX_RETAINED_TICKS) - - def begin_tick(self, tick: int) -> None: - max_age = self._PENDING_GIVE_MAX_AGE_TICKS - for key, entry in list(self._pending_gives.items()): - recorded = int(entry.get("recorded_at_tick", tick)) - if tick - recorded > max_age: - del self._pending_gives[key] - self._current = TickMeasurement(tick=tick) - - def record_action(self, action: Action, result: ActionResult) -> None: - if self._current is None: - return - - self._current.total_actions += 1 - self._current.action_verbs[action.verb] += 1 - self.action_trace.append( - { - "tick": self._current.tick, - "agent_id": action.agent_id, - "verb": action.verb, - "parameters": copy.deepcopy(action.parameters), - "success": bool(result.success), - "description": result.description, - "state_changes": copy.deepcopy(result.state_changes), - "events": list(result.events), - } - ) - - if not result.success: - self._current.failed_actions += 1 - - verb = action.verb.lower().strip() - - if verb in ("say", "speak", "talk", "tell") and result.success: - self._current.communication_count += 1 - - if verb in ("give", "offer", "hand") and result.success: - peer_id = result.observer_meta.get("peer_agent_id") - transfer = { - "from": action.agent_id, - "to": peer_id or action.parameters.get("target", ""), - "item": action.parameters.get("item", ""), - "quantity": int(action.parameters.get("quantity", 1)), - } - self._current.resource_transfers.append(transfer) - if peer_id: - key = f"{action.agent_id}->{peer_id}" - self._pending_gives[key] = { - "recorded_at_tick": self._current.tick, - "transfer": transfer, - } - - if verb in ("take", "grab", "pickup", "pick_up") and result.success: - peer_id = result.observer_meta.get("peer_agent_id") - if peer_id: - reverse_key = f"{peer_id}->{action.agent_id}" - if reverse_key in self._pending_gives: - self._current.cooperation_events += 1 - del self._pending_gives[reverse_key] - - def record_locations(self, agent_locations: dict[str, str]) -> None: - if self._current is not None: - self._current.agent_locations = dict(agent_locations) - - def record_world_state(self, world: World) -> None: - if self._current is None: - return - agents = list(world.agents.values()) - self._current.alive_agents = sum(1 for agent in agents if agent.alive) - self._current.dead_agents = sum(1 for agent in agents if not agent.alive) - self._current.births = int(getattr(world, "total_births", 0)) - self._current.deaths = int(getattr(world, "total_deaths", 0)) - self._current.resource_total = self._positive_resource_total(world) - self._current.disease_infected = sum( - 1 - for agent in agents - if self._has_active_infection(agent) - ) - self._current.season = str(getattr(world, "environment", {}).get("season", "")) - - def end_tick(self) -> None: - if self._current is not None: - self.ticks.append(self._current) - self._trim_retained_ticks() - self._current = None - - def _trim_retained_ticks(self) -> None: - extra = len(self.ticks) - self._max_retained_ticks - if extra > 0: - del self.ticks[:extra] - - @staticmethod - def _positive_resource_total(world: World) -> int: - location_total = sum( - int(qty) - for items in world.location_items.values() - for qty in items.values() - if int(qty) > 0 - ) - inventory_total = sum( - int(qty) - for agent in world.agents.values() - for qty in agent.inventory.values() - if int(qty) > 0 - ) - return location_total + inventory_total - - @staticmethod - def _has_active_infection(agent: AgentState) -> bool: - if not agent.alive or not bool(agent.contagion_profile.get("infectious", False)): - return False - status = agent.contagion_profile.get("status") - return status in (None, "infected") - - def summary(self) -> dict[str, Any]: - """Aggregate metrics across all recorded ticks.""" - if not self.ticks: - return {"ticks_recorded": 0} - - total_verbs: Counter[str] = Counter() - total_comms = 0 - total_transfers = 0 - total_cooperation = 0 - total_failed = 0 - total_actions = 0 - - colocation_counts: Counter[tuple[str, str]] = Counter() - - for tick in self.ticks: - total_verbs += tick.action_verbs - total_comms += tick.communication_count - total_transfers += len(tick.resource_transfers) - total_cooperation += tick.cooperation_events - total_failed += tick.failed_actions - total_actions += tick.total_actions - - agents_at: dict[str, list[str]] = {} - for agent_id, loc in tick.agent_locations.items(): - agents_at.setdefault(loc, []).append(agent_id) - for loc_agents in agents_at.values(): - for i, a in enumerate(loc_agents): - for b in loc_agents[i + 1:]: - pair = tuple(sorted([a, b])) - colocation_counts[pair] += 1 # type: ignore[arg-type] - - top_colocations = colocation_counts.most_common(10) - - return { - "ticks_recorded": len(self.ticks), - "action_distribution": dict(total_verbs.most_common()), - "total_communications": total_comms, - "total_resource_transfers": total_transfers, - "total_cooperation_events": total_cooperation, - "total_failed_actions": total_failed, - "total_actions": total_actions, - "failure_rate": total_failed / max(1, total_actions), - "top_colocation_pairs": [ - {"agents": list(pair), "ticks_together": count} - for pair, count in top_colocations - ], - "final_alive_agents": self.ticks[-1].alive_agents, - "final_resource_total": self.ticks[-1].resource_total, - "peak_disease_infected": max(t.disease_infected for t in self.ticks), - } - - def to_json(self) -> list[dict[str, Any]]: - """Serialize all tick measurements for storage/comparison.""" - return [ - { - "tick": t.tick, - "action_verbs": dict(t.action_verbs), - "resource_transfers": t.resource_transfers, - "communication_count": t.communication_count, - "cooperation_events": t.cooperation_events, - "failed_actions": t.failed_actions, - "total_actions": t.total_actions, - "agent_locations": t.agent_locations, - "alive_agents": t.alive_agents, - "dead_agents": t.dead_agents, - "births": t.births, - "deaths": t.deaths, - "resource_total": t.resource_total, - "disease_infected": t.disease_infected, - "season": t.season, - } - for t in self.ticks - ] diff --git a/antelab/engine/org.py b/antelab/engine/org.py deleted file mode 100644 index 0dbe369..0000000 --- a/antelab/engine/org.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Organization system: departments, roles, rules, and formal org state. - -Unlike the passive observer-only clustering in world.py, this module provides -agent-visible organization state. Agents perceive departments, roles, and rules -as part of their company context, enabling them to make informed decisions about -organizational structure. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class Department: - """A formal department or team within the company.""" - - dept_id: str - name: str - lead_agent_id: str - member_agent_ids: list[str] = field(default_factory=list) - created_tick: int = 0 - parent_dept_id: str | None = None - suggestion_id: str | None = None # trace back to originating suggestion - - def to_dict(self) -> dict[str, Any]: - return { - "dept_id": self.dept_id, - "name": self.name, - "lead_agent_id": self.lead_agent_id, - "member_agent_ids": list(self.member_agent_ids), - "created_tick": self.created_tick, - "parent_dept_id": self.parent_dept_id, - } - - -@dataclass -class Role: - """A named role held by an agent, optionally within a department.""" - - role_id: str - name: str - holder_agent_id: str - department_id: str | None = None - created_tick: int = 0 - - def to_dict(self) -> dict[str, Any]: - return { - "role_id": self.role_id, - "name": self.name, - "holder_agent_id": self.holder_agent_id, - "department_id": self.department_id, - "created_tick": self.created_tick, - } - - -@dataclass -class Rule: - """A company rule or policy created by agents.""" - - rule_id: str - description: str - created_by_agent_id: str - created_tick: int = 0 - active: bool = True - - def to_dict(self) -> dict[str, Any]: - return { - "rule_id": self.rule_id, - "description": self.description, - "created_by_agent_id": self.created_by_agent_id, - "created_tick": self.created_tick, - "active": self.active, - } - - -@dataclass -class OrganizationState: - """Runtime organization state for company emergence scenarios. - - Agent-visible: included in perception via company_context. - Replaces the passive observer-only clustering in world.py. - """ - - departments: list[Department] = field(default_factory=list) - roles: list[Role] = field(default_factory=list) - rules: list[Rule] = field(default_factory=list) - charter_artifact_id: str | None = None - max_departments: int = 12 - min_members_for_team: int = 2 - - # -- Department management -- - - def get_department(self, dept_id: str) -> Department | None: - for dept in self.departments: - if dept.dept_id == dept_id: - return dept - return None - - def add_department( - self, - name: str, - lead_agent_id: str, - member_agent_ids: list[str] | None = None, - created_tick: int = 0, - parent_dept_id: str | None = None, - suggestion_id: str | None = None, - ) -> Department | None: - """Create a new department. Returns None if at max capacity.""" - if len(self.departments) >= self.max_departments: - return None - dept = Department( - dept_id=f"dept-{len(self.departments) + 1:03d}", - name=name, - lead_agent_id=lead_agent_id, - member_agent_ids=list(member_agent_ids or []), - created_tick=created_tick, - parent_dept_id=parent_dept_id, - suggestion_id=suggestion_id, - ) - self.departments.append(dept) - return dept - - def remove_department(self, dept_id: str) -> bool: - for i, dept in enumerate(self.departments): - if dept.dept_id == dept_id: - self.departments.pop(i) - return True - return False - - def assign_agent_to_department(self, agent_id: str, dept_id: str) -> bool: - dept = self.get_department(dept_id) - if dept is None: - return False - if agent_id not in dept.member_agent_ids: - dept.member_agent_ids.append(agent_id) - return True - - def remove_agent_from_department(self, agent_id: str, dept_id: str) -> bool: - dept = self.get_department(dept_id) - if dept is None: - return False - if agent_id in dept.member_agent_ids: - dept.member_agent_ids.remove(agent_id) - return True - return False - - def agent_departments(self, agent_id: str) -> list[Department]: - """All departments the agent belongs to (as member or lead).""" - return [ - d for d in self.departments - if agent_id in d.member_agent_ids or d.lead_agent_id == agent_id - ] - - def department_count(self) -> int: - return len(self.departments) - - # -- Role management -- - - def get_role(self, role_id: str) -> Role | None: - for role in self.roles: - if role.role_id == role_id: - return role - return None - - def add_role( - self, - name: str, - holder_agent_id: str, - department_id: str | None = None, - created_tick: int = 0, - ) -> Role: - role = Role( - role_id=f"role-{len(self.roles) + 1:03d}", - name=name, - holder_agent_id=holder_agent_id, - department_id=department_id, - created_tick=created_tick, - ) - self.roles.append(role) - return role - - def remove_role(self, role_id: str) -> bool: - for i, role in enumerate(self.roles): - if role.role_id == role_id: - self.roles.pop(i) - return True - return False - - def agent_roles(self, agent_id: str) -> list[Role]: - return [r for r in self.roles if r.holder_agent_id == agent_id] - - # -- Rule management -- - - def add_rule( - self, - description: str, - created_by_agent_id: str, - created_tick: int = 0, - ) -> Rule: - rule = Rule( - rule_id=f"rule-{len(self.rules) + 1:03d}", - description=description, - created_by_agent_id=created_by_agent_id, - created_tick=created_tick, - active=True, - ) - self.rules.append(rule) - return rule - - def deactivate_rule(self, rule_id: str) -> bool: - rule = self._get_rule(rule_id) - if rule is None: - return False - rule.active = False - return True - - def _get_rule(self, rule_id: str) -> Rule | None: - for rule in self.rules: - if rule.rule_id == rule_id: - return rule - return None - - def active_rules(self) -> list[Rule]: - return [r for r in self.rules if r.active] - - # -- Serialization -- - - def to_summary(self) -> dict[str, Any]: - """Produce an agent-visible summary of the organization state.""" - return { - "department_count": len(self.departments), - "departments": [d.to_dict() for d in self.departments], - "role_count": len(self.roles), - "roles": [r.to_dict() for r in self.roles], - "rule_count": len(self.rules), - "active_rules": [r.to_dict() for r in self.active_rules()], - "has_charter": self.charter_artifact_id is not None, - } diff --git a/antelab/engine/pattern.py b/antelab/engine/pattern.py deleted file mode 100644 index 55c198a..0000000 --- a/antelab/engine/pattern.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Pattern detector: sliding-window behavior analysis and suggestion generation. - -Core innovation of the company emergence loop. The detector watches agent -actions across ticks, categorizes them, and when a category exceeds the -threshold within the sliding window, generates a "suggestion" event for -that agent. Agents can accept, modify, or ignore suggestions via new verbs -(see antelab.engine.org). -""" - -from __future__ import annotations - -import enum -from dataclasses import dataclass, field -from typing import Any - - -class BehaviorCategory(enum.Enum): - DELIVERY = "delivery" - CRAFTING = "crafting" - COORDINATION = "coordination" - DELEGATION = "delegation" - PLANNING = "planning" - - -# Verb-to-category mapping. Aliases cover the full resolver map in world.py. -_VERB_CATEGORY: dict[str, BehaviorCategory] = { - "deliver": BehaviorCategory.DELIVERY, - "ship": BehaviorCategory.DELIVERY, - "craft": BehaviorCategory.CRAFTING, - "make": BehaviorCategory.CRAFTING, - "build": BehaviorCategory.CRAFTING, - "say": BehaviorCategory.COORDINATION, - "speak": BehaviorCategory.COORDINATION, - "talk": BehaviorCategory.COORDINATION, - "give": BehaviorCategory.COORDINATION, - "take": BehaviorCategory.COORDINATION, - "examine": BehaviorCategory.COORDINATION, - "inspect": BehaviorCategory.COORDINATION, - "recruit": BehaviorCategory.DELEGATION, - "hire": BehaviorCategory.DELEGATION, - "create_role": BehaviorCategory.DELEGATION, - "form_team": BehaviorCategory.DELEGATION, - "write_artifact": BehaviorCategory.PLANNING, - "write_note": BehaviorCategory.PLANNING, - "read_artifact": BehaviorCategory.PLANNING, - "read_note": BehaviorCategory.PLANNING, - "update_artifact": BehaviorCategory.PLANNING, - "revise_artifact": BehaviorCategory.PLANNING, -} - -# Suggestion message templates per category. -_SUGGESTION_MESSAGES: dict[BehaviorCategory, str] = { - BehaviorCategory.DELIVERY: ( - "You have been delivering frequently. " - "Consider forming a Delivery Department to streamline shipping." - ), - BehaviorCategory.CRAFTING: ( - "You have been crafting often. " - "Consider forming a Production Department to coordinate building." - ), - BehaviorCategory.COORDINATION: ( - "You are frequently communicating and deciding with others. " - "Consider establishing a joint decision rule to formalize collaboration." - ), - BehaviorCategory.DELEGATION: ( - "You keep assigning work and hiring. " - "Consider formalizing a Manager role to lead the growing team." - ), - BehaviorCategory.PLANNING: ( - "You have been writing and reading company knowledge consistently. " - "Consider creating a company charter to codify practices." - ), -} - - -@dataclass -class PatternDetector: - """Sliding-window behavior pattern detector. - - Tracks each agent's categorized actions over time. When a behavior - category exceeds the threshold within the sliding window, generates - suggestion events for the agent. - """ - - window_ticks: int = 20 - threshold: int = 5 - suggestion_expiry_ticks: int = 5 - # agent_id -> list of (tick, BehaviorCategory) tuples - action_log: dict[str, list[tuple[int, BehaviorCategory]]] = field( - default_factory=dict - ) - - def track_action(self, agent_id: str, verb: str, _params: dict[str, Any] | None = None) -> None: - """Categorize and log an action for the given agent.""" - category = _VERB_CATEGORY.get(verb) - if category is None: - return - entry = (0, category) # tick will be filled by track_tick - self.action_log.setdefault(agent_id, []).append(entry) - - def track_action_at_tick( - self, agent_id: str, tick: int, verb: str, _params: dict[str, Any] | None = None - ) -> None: - """Categorize and log an action with the specific tick.""" - category = _VERB_CATEGORY.get(verb) - if category is None: - return - self.action_log.setdefault(agent_id, []).append((tick, category)) - - def prune_logs(self, current_tick: int) -> None: - """Remove entries outside the sliding window for all agents.""" - cutoff = current_tick - self.window_ticks - for agent_id in list(self.action_log.keys()): - self.action_log[agent_id] = [ - (t, c) for t, c in self.action_log[agent_id] if t > cutoff - ] - if not self.action_log[agent_id]: - del self.action_log[agent_id] - - def detect( - self, agent_id: str, current_tick: int - ) -> dict[BehaviorCategory, int]: - """Count actions per category within the sliding window. - - Returns only categories with at least one action recorded. - """ - cutoff = current_tick - self.window_ticks - entries = self.action_log.get(agent_id, []) - counts: dict[BehaviorCategory, int] = {} - for tick, category in entries: - if tick > cutoff: - counts[category] = counts.get(category, 0) + 1 - return counts - - def generate_suggestions( - self, agent_id: str, current_tick: int, company_stage: str = "garage" - ) -> list[dict[str, Any]]: - """Generate suggestion dicts for categories exceeding the threshold. - - Returns a list of suggestion dicts, each containing: - suggestion_id, category, message, target_agent_id, tick, stage - """ - counts = self.detect(agent_id, current_tick) - suggestions: list[dict[str, Any]] = [] - for category, count in counts.items(): - if count >= self.threshold: - msg = _SUGGESTION_MESSAGES.get( - category, - f"You have been doing {category.value} regularly. Formalize this?", - ) - suggestion: dict[str, Any] = { - "suggestion_id": ( - f"sug-{agent_id}-{category.value}-{current_tick}" - ), - "category": category.value, - "message": msg, - "target_agent_id": agent_id, - "tick": current_tick, - "stage": company_stage, - } - suggestions.append(suggestion) - return suggestions diff --git a/antelab/engine/receipts.py b/antelab/engine/receipts.py deleted file mode 100644 index 5114332..0000000 --- a/antelab/engine/receipts.py +++ /dev/null @@ -1,554 +0,0 @@ -"""Deterministic receipt verification for agent claims. - -The receipt layer is evaluator-only: it consumes action traces and state-change -evidence after the world has resolved actions. It does not mutate the world and -does not feed receipt evidence back into live agent perception. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Literal - -ClaimKind = Literal["completion", "intent", "handoff", "assumption"] -ReceiptStatus = Literal["supported", "unsupported", "unknown"] - -_SPEECH_VERBS = {"say", "speak", "talk", "tell"} -_MATERIAL_ACTION_CATEGORIES: dict[str, str] = { - "deliver": "deliver", - "ship": "deliver", - "store": "store", - "stash": "store", - "give": "give", - "offer": "give", - "hand": "give", - "write_artifact": "artifact", - "write_note": "artifact", - "update_artifact": "artifact", - "revise_artifact": "artifact", - "recruit": "recruit", - "hire": "recruit", - "create_role": "role", - "form_team": "team", - "accept_suggestion": "team", - "modify_suggestion": "team", -} -_CATEGORY_EVIDENCE_MARKERS: dict[str, tuple[str, ...]] = { - "deliver": ("company.demand", "company_cash", "request_id", "delivered"), - "store": ("stored", "location_items", "agent_inventory"), - "give": ("agent_inventory", "inventory", "peer_agent_id"), - "artifact": ("artifact", "company.artifacts", "artifact_id", "revision"), - "recruit": ("candidate", "pending_recruits", "company_cash"), - "role": ("role_id", "role_name", "company.org.roles"), - "team": ("dept_id", "team_name", "department_created", "company.org.departments"), -} -_COMPLETION_TERMS = ( - "done", - "complete", - "completed", - "finished", - "delivered", - "shipped", - "stored", - "handed off", - "handed it off", - "sent", - "wrote", - "written", - "updated", - "recruited", - "hired", - "created role", - "formed team", -) -_AMBIGUOUS_COMPLETION_TERMS = ( - "handled it", - "took care of it", - "sorted it", - "all set", -) - - -@dataclass(frozen=True) -class Claim: - """A deterministic claim extracted from action metadata or narrow speech.""" - - id: str - tick: int - agent_id: str - kind: ClaimKind - text: str - target: str | None = None - required_evidence: tuple[str, ...] = () - source_verb: str = "" - evidence_category: str | None = None - ambiguous: bool = False - - def to_json(self) -> dict[str, Any]: - return { - "id": self.id, - "tick": self.tick, - "agent_id": self.agent_id, - "kind": self.kind, - "text": self.text, - "target": self.target, - "required_evidence": list(self.required_evidence), - "source_verb": self.source_verb, - "evidence_category": self.evidence_category, - "ambiguous": self.ambiguous, - } - - -@dataclass(frozen=True) -class ActionEvidence: - """Action trace captured by the observer after physics resolution.""" - - tick: int - agent_id: str - verb: str - parameters: dict[str, Any] = field(default_factory=dict) - success: bool = False - description: str = "" - state_changes: dict[str, Any] = field(default_factory=dict) - events: tuple[str, ...] = () - - def to_json(self) -> dict[str, Any]: - return { - "tick": self.tick, - "agent_id": self.agent_id, - "verb": self.verb, - "parameters": dict(self.parameters), - "success": self.success, - "description": self.description, - "state_changes": dict(self.state_changes), - "events": list(self.events), - } - - -@dataclass(frozen=True) -class StateChangeEvidence: - """A flattened state transition path observed after an action.""" - - tick: int - path: str - before: Any - after: Any - - def to_json(self) -> dict[str, Any]: - return { - "tick": self.tick, - "path": self.path, - "before": self.before, - "after": self.after, - } - - -@dataclass(frozen=True) -class Receipt: - """Verifier verdict for one claim.""" - - claim: Claim - status: ReceiptStatus - label: str - supporting_actions: tuple[ActionEvidence, ...] = () - supporting_state_changes: tuple[StateChangeEvidence, ...] = () - missing_evidence: tuple[str, ...] = () - explanation: str = "" - - @property - def claim_id(self) -> str: - return self.claim.id - - def to_json(self) -> dict[str, Any]: - return { - "claim_id": self.claim.id, - "claim": self.claim.to_json(), - "status": self.status, - "label": self.label, - "supporting_actions": [action.to_json() for action in self.supporting_actions], - "supporting_state_changes": [ - state_change.to_json() - for state_change in self.supporting_state_changes - ], - "missing_evidence": list(self.missing_evidence), - "explanation": self.explanation, - } - - -@dataclass(frozen=True) -class ReceiptReport: - """Run-level receipt report.""" - - run_id: str - receipts: tuple[Receipt, ...] = field(default_factory=tuple) - - @property - def summary(self) -> dict[str, int]: - supported = sum(1 for receipt in self.receipts if receipt.status == "supported") - unsupported = sum(1 for receipt in self.receipts if receipt.status == "unsupported") - unknown = sum(1 for receipt in self.receipts if receipt.status == "unknown") - false_completion_claims = sum( - 1 for receipt in self.receipts if receipt.label == "FalseCompletionClaim" - ) - return { - "supported": supported, - "unsupported": unsupported, - "unknown": unknown, - "false_completion_claims": false_completion_claims, - } - - @property - def failures(self) -> tuple[Receipt, ...]: - return tuple( - receipt - for receipt in self.receipts - if receipt.status == "unsupported" - or (receipt.status == "unknown" and receipt.label == "ReceiptMissing") - ) - - def to_json(self) -> dict[str, Any]: - return { - "run_id": self.run_id, - "summary": self.summary, - "receipts": [receipt.to_json() for receipt in self.receipts], - "failures": [_failure_to_json(receipt) for receipt in self.failures], - } - - -def action_evidence_from_trace(trace: dict[str, Any]) -> ActionEvidence: - """Convert an observer trace dict to typed evidence.""" - return ActionEvidence( - tick=int(trace.get("tick", 0)), - agent_id=str(trace.get("agent_id", "")), - verb=str(trace.get("verb", "")), - parameters=dict(trace.get("parameters", {})), - success=bool(trace.get("success", False)), - description=str(trace.get("description", "")), - state_changes=dict(trace.get("state_changes", {})), - events=tuple(str(event) for event in trace.get("events", [])), - ) - - -def build_receipt_report( - *, - run_id: str, - actions: list[ActionEvidence] | tuple[ActionEvidence, ...], - evidence_window_before: int = 2, - evidence_window_after: int = 5, -) -> ReceiptReport: - """Build a deterministic receipt report from action evidence.""" - action_list = tuple(actions) - claims = _extract_claims(action_list) - receipts = tuple( - _verify_claim( - claim, - action_list, - evidence_window_before=evidence_window_before, - evidence_window_after=evidence_window_after, - ) - for claim in claims - ) - return ReceiptReport(run_id=run_id, receipts=receipts) - - -def render_receipt_markdown(report: ReceiptReport) -> str: - """Render the receipt report as a human-readable autopsy.""" - summary = report.summary - lines = [ - "# Agent Receipt Autopsy", - "", - "## Summary", - "", - f"- Supported: {summary['supported']}", - f"- Unsupported: {summary['unsupported']}", - f"- Unknown: {summary['unknown']}", - f"- False completion claims: {summary['false_completion_claims']}", - "", - "## Unsupported Claims", - "", - ] - unsupported = [receipt for receipt in report.receipts if receipt.status == "unsupported"] - if unsupported: - for receipt in unsupported: - lines.append( - f"- Tick {receipt.claim.tick} agent {receipt.claim.agent_id}: " - f"{receipt.claim.text} [{receipt.label}]" - ) - lines.append(f" Evidence gap: {', '.join(receipt.missing_evidence)}") - else: - lines.append("- None") - lines.extend(["", "## Missing Evidence", ""]) - if report.failures: - for receipt in report.failures: - lines.append( - f"- {receipt.label}: {receipt.claim.text} -> " - f"{', '.join(receipt.missing_evidence)}" - ) - else: - lines.append("- None") - lines.extend(["", "## Supported Receipts", ""]) - supported = [receipt for receipt in report.receipts if receipt.status == "supported"] - if supported: - for receipt in supported: - paths = ", ".join( - state_change.path for state_change in receipt.supporting_state_changes - ) - lines.append( - f"- Tick {receipt.claim.tick} agent {receipt.claim.agent_id}: " - f"{receipt.claim.text} -> {paths}" - ) - else: - lines.append("- None") - lines.append("") - return "\n".join(lines) - - -def _extract_claims(actions: tuple[ActionEvidence, ...]) -> tuple[Claim, ...]: - claims: list[Claim] = [] - for index, action in enumerate(actions, start=1): - verb = _canonical_verb(action.verb) - if verb in _MATERIAL_ACTION_CATEGORIES: - category = _MATERIAL_ACTION_CATEGORIES[verb] - target = _structured_target(verb, action.parameters) - claims.append( - Claim( - id=f"claim-{action.tick}-{action.agent_id}-{index}", - tick=action.tick, - agent_id=action.agent_id, - kind="completion", - text=_structured_claim_text(verb, target), - target=target, - required_evidence=(f"state change for {verb}",), - source_verb=verb, - evidence_category=category, - ) - ) - continue - - if verb not in _SPEECH_VERBS: - continue - message = str(action.parameters.get("message", "")).strip() - if not message: - continue - lowered = message.lower() - if any(term in lowered for term in _AMBIGUOUS_COMPLETION_TERMS): - claims.append( - Claim( - id=f"claim-{action.tick}-{action.agent_id}-{index}", - tick=action.tick, - agent_id=action.agent_id, - kind="completion", - text=message, - required_evidence=("deterministic claim target",), - source_verb=verb, - ambiguous=True, - ) - ) - continue - if any(term in lowered for term in _COMPLETION_TERMS): - claims.append( - Claim( - id=f"claim-{action.tick}-{action.agent_id}-{index}", - tick=action.tick, - agent_id=action.agent_id, - kind="completion", - text=message, - target=_speech_target(lowered), - required_evidence=("state change matching the completion claim",), - source_verb=verb, - evidence_category=_speech_evidence_category(lowered), - ) - ) - return tuple(claims) - - -def _verify_claim( - claim: Claim, - actions: tuple[ActionEvidence, ...], - *, - evidence_window_before: int, - evidence_window_after: int, -) -> Receipt: - if claim.ambiguous: - return Receipt( - claim=claim, - status="unknown", - label="ReceiptMissing", - missing_evidence=("deterministic claim target",), - explanation="The claim is too vague for deterministic state verification.", - ) - - if claim.source_verb in _MATERIAL_ACTION_CATEGORIES: - candidates = [ - action - for action in actions - if action.tick == claim.tick - and action.agent_id == claim.agent_id - and _canonical_verb(action.verb) == claim.source_verb - ] - else: - start = claim.tick - evidence_window_before - end = claim.tick + evidence_window_after - candidates = [ - action - for action in actions - if action.agent_id == claim.agent_id - and start <= action.tick <= end - and _canonical_verb(action.verb) in _MATERIAL_ACTION_CATEGORIES - ] - - supporting_actions: list[ActionEvidence] = [] - supporting_state_changes: list[StateChangeEvidence] = [] - for action in candidates: - category = claim.evidence_category - if category is None: - category = _MATERIAL_ACTION_CATEGORIES.get(_canonical_verb(action.verb)) - changes = tuple(_flatten_state_changes(action.state_changes, tick=action.tick)) - matching = tuple( - change - for change in changes - if category is None or _state_change_matches_category(change, category) - ) - if action.success and matching: - supporting_actions.append(action) - supporting_state_changes.extend(matching) - - if supporting_state_changes: - return Receipt( - claim=claim, - status="supported", - label="ReceiptVerified", - supporting_actions=tuple(supporting_actions), - supporting_state_changes=tuple(supporting_state_changes), - explanation="The claim is backed by matching action and state-change evidence.", - ) - - label = "FalseCompletionClaim" if claim.source_verb in _SPEECH_VERBS else "ReceiptMissing" - return Receipt( - claim=claim, - status="unsupported", - label=label, - supporting_actions=tuple(candidates), - missing_evidence=claim.required_evidence, - explanation="No matching state change was found for this claim.", - ) - - -def _flatten_state_changes( - changes: dict[str, Any], - *, - tick: int, - prefix: str = "", -) -> tuple[StateChangeEvidence, ...]: - flattened: list[StateChangeEvidence] = [] - for raw_key, value in changes.items(): - key = str(raw_key) - path = f"{prefix}.{key}" if prefix else key - if isinstance(value, dict) and {"before", "after"}.issubset(value): - flattened.append( - StateChangeEvidence( - tick=tick, - path=path, - before=value.get("before"), - after=value.get("after"), - ) - ) - elif isinstance(value, dict) and value: - flattened.extend(_flatten_state_changes(value, tick=tick, prefix=path)) - else: - flattened.append( - StateChangeEvidence(tick=tick, path=path, before=None, after=value) - ) - return tuple(flattened) - - -def _state_change_matches_category( - state_change: StateChangeEvidence, - category: str, -) -> bool: - markers = _CATEGORY_EVIDENCE_MARKERS.get(category, ()) - path = state_change.path.lower() - after = str(state_change.after).lower() - before = str(state_change.before).lower() - return any( - marker.lower() in path or marker.lower() in after or marker.lower() in before - for marker in markers - ) - - -def _canonical_verb(verb: str) -> str: - return verb.lower().strip() - - -def _structured_target(verb: str, parameters: dict[str, Any]) -> str | None: - if verb in {"deliver", "ship"}: - return _first_present(parameters, ("request_id", "demand", "item")) - if verb in {"store", "stash", "give", "offer", "hand"}: - return _first_present(parameters, ("item", "target")) - if verb in {"write_artifact", "write_note", "update_artifact", "revise_artifact"}: - return _first_present(parameters, ("artifact_id", "title")) - if verb in {"recruit", "hire"}: - return _first_present(parameters, ("candidate_id", "target")) - if verb == "create_role": - return _first_present(parameters, ("role_name",)) - if verb == "form_team": - return _first_present(parameters, ("team_name",)) - return None - - -def _first_present(parameters: dict[str, Any], keys: tuple[str, ...]) -> str | None: - for key in keys: - value = parameters.get(key) - if value is not None and str(value).strip(): - return str(value) - return None - - -def _structured_claim_text(verb: str, target: str | None) -> str: - if target: - return f"{verb} {target}" - return verb - - -def _speech_target(lowered_message: str) -> str | None: - for marker in ("delivered", "shipped", "stored", "sent", "recruited", "hired"): - if marker in lowered_message: - return marker - if "artifact" in lowered_message or "wrote" in lowered_message or "updated" in lowered_message: - return "artifact" - if "role" in lowered_message: - return "role" - if "team" in lowered_message: - return "team" - return "completion" - - -def _speech_evidence_category(lowered_message: str) -> str | None: - if "delivered" in lowered_message or "shipped" in lowered_message: - return "deliver" - if "stored" in lowered_message: - return "store" - if "handed" in lowered_message or "sent" in lowered_message: - return "give" - if "artifact" in lowered_message or "wrote" in lowered_message or "updated" in lowered_message: - return "artifact" - if "recruited" in lowered_message or "hired" in lowered_message: - return "recruit" - if "role" in lowered_message: - return "role" - if "team" in lowered_message: - return "team" - return None - - -def _failure_to_json(receipt: Receipt) -> dict[str, Any]: - return { - "claim_id": receipt.claim.id, - "tick": receipt.claim.tick, - "agent_id": receipt.claim.agent_id, - "claim": receipt.claim.text, - "status": receipt.status, - "label": receipt.label, - "missing_evidence": list(receipt.missing_evidence), - "explanation": receipt.explanation, - } diff --git a/antelab/engine/space.py b/antelab/engine/space.py deleted file mode 100644 index ab04950..0000000 --- a/antelab/engine/space.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Office space: capacity management, expansion, and crowding pressure.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -_SPACE_STAGES = ["garage", "office", "floor", "campus"] - - -@dataclass -class OfficeSpace: - """Office layout state with capacity and expansion tracking.""" - - stage: str = "garage" - capacity: int = 3 - expansion_cost: dict[str, int] = field( - default_factory=lambda: {"office": 50, "floor": 200, "campus": 1000} - ) - capacity_pressure_ticks: int = 3 - stress_per_overcapacity: int = 2 - ticks_over_capacity: int = 0 - layout: dict[str, Any] = field(default_factory=dict) - - def current_capacity(self) -> int: - return self.capacity - - def check_capacity(self, team_size: int) -> bool: - """True if within capacity, False if over.""" - return team_size <= self.capacity - - def occupancy(self, team_size: int) -> tuple[int, int]: - """Returns (current_occupancy, capacity).""" - return team_size, self.capacity - - def apply_capacity_pressure( - self, team_size: int - ) -> dict[str, Any]: - """Check capacity and apply pressure effects. - - Returns a dict with: - over_capacity: bool - stress_delta: int (stress per agent, if over capacity) - expansion_triggered: bool - stage_advanced_to: str or None - """ - result: dict[str, Any] = { - "over_capacity": False, - "stress_delta": 0, - "expansion_triggered": False, - "stage_advanced_to": None, - } - - if team_size <= self.capacity: - self.ticks_over_capacity = 0 - return result - - self.ticks_over_capacity += 1 - result["over_capacity"] = True - result["stress_delta"] = self.stress_per_overcapacity - - return result - - def auto_expand(self, team_size: int, company_cash: int) -> tuple[bool, int]: - """Auto-expand if over capacity for enough ticks and cash available. - - Returns (expanded, cost_deducted). - """ - if team_size <= self.capacity: - return False, 0 - if self.ticks_over_capacity < self.capacity_pressure_ticks: - return False, 0 - - next_stage = self._next_stage() - if next_stage is None: - return False, 0 - - cost = self.expansion_cost.get(next_stage, 0) - if company_cash < cost: - return False, 0 - - self.stage = next_stage - self.capacity = _STAGE_CAPACITY.get(next_stage, self.capacity) - self.ticks_over_capacity = 0 - return True, cost - - def expand(self, target_stage: str | None = None) -> tuple[bool, int]: - """Explicitly expand to the next stage (or a target stage). - - Returns (success, cost_deducted). - """ - stage = target_stage or self._next_stage() - if stage is None: - return False, 0 - if stage not in self.expansion_cost: - return False, 0 - - cost = self.expansion_cost[stage] - self.stage = stage - self.capacity = _STAGE_CAPACITY.get(stage, self.capacity) - self.ticks_over_capacity = 0 - return True, cost - - def _next_stage(self) -> str | None: - stages = _SPACE_STAGES - try: - idx = stages.index(self.stage) - except ValueError: - return None - if idx + 1 < len(stages): - return stages[idx + 1] - return None - - def next_stage_name(self) -> str | None: - return self._next_stage() - - def to_layout(self) -> dict[str, Any]: - """Generate frontend-usable layout data.""" - return { - "stage": self.stage, - "capacity": self.capacity, - "stages": _SPACE_STAGES, - "stage_index": _SPACE_STAGES.index(self.stage) - if self.stage in _SPACE_STAGES else 0, - } - - def to_dict(self) -> dict[str, Any]: - return { - "stage": self.stage, - "capacity": self.capacity, - "ticks_over_capacity": self.ticks_over_capacity, - } - - -_STAGE_CAPACITY: dict[str, int] = { - "garage": 3, - "office": 8, - "floor": 20, - "campus": 50, -} diff --git a/antelab/engine/tick.py b/antelab/engine/tick.py deleted file mode 100644 index 1e20a42..0000000 --- a/antelab/engine/tick.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Tick runner: advances the simulation one step at a time.""" - -from __future__ import annotations - -import asyncio -import logging -from dataclasses import dataclass, field - -from antelab.engine.agent import Agent -from antelab.engine.measurement import ExperimentObserver -from antelab.engine.types import ActionResult -from antelab.engine.world import World -from antelab.llm.client import LLMClient - -logger = logging.getLogger(__name__) - - -@dataclass -class TickResult: - """Summary of what happened during a single tick.""" - - tick: int - results: list[ActionResult] = field(default_factory=list) - - -class TickRunner: - """Orchestrates the perceive-decide-act cycle for all agents.""" - - def __init__( - self, - world: World, - agents: list[Agent], - *, - template_llm: LLMClient | None = None, - ) -> None: - self.world = world - self.agents = agents - self._template_llm = template_llm - self.observer = ExperimentObserver() - - async def run_tick(self) -> TickResult: - """Execute one simulation tick: all agents perceive, decide, and act.""" - tick_result = TickResult(tick=self.world.tick) - self.observer.begin_tick(self.world.tick) - - active_agents = [ - agent - for agent in self.agents - if agent.id in self.world.agents and self.world.agents[agent.id].alive - ] - perceptions = {agent.id: agent.perceive(self.world) for agent in active_agents} - decisions = {} - - async def _decide(a: Agent): - return a, await a.decide(perceptions[a.id]) - - decide_results = await asyncio.gather(*[_decide(a) for a in active_agents]) - for agent, action in decide_results: - decisions[agent.id] = (agent, action) - - resolution_order = sorted(decisions.keys()) - results_by_agent = {} - - for agent_id in resolution_order: - agent, action = decisions[agent_id] - result = self.world.apply_action(action) - results_by_agent[agent_id] = result - tick_result.results.append(result) - self.observer.record_action(action, result) - - logger.debug( - "Tick %d | %s -> %s | %s", - self.world.tick, - agent.name, - action.verb, - result.description, - ) - - for agent_id in resolution_order: - agent, _ = decisions[agent_id] - agent.remember(results_by_agent[agent_id]) - - self.world.advance_tick() - self._materialize_newborn_agents() - self._materialize_recruited_agents() - self.observer.record_locations({ - a_id: a_state.location - for a_id, a_state in self.world.agents.items() - if a_state.alive - }) - self.observer.record_world_state(self.world) - self.observer.end_tick() - return tick_result - - def _materialize_newborn_agents(self) -> None: - births = self.world.consume_pending_births() - if not births: - return - - template_llm = self._template_llm or (self.agents[0].llm if self.agents else None) - if template_llm is None: - return - - memory_size = self.world.axioms.memory_size if hasattr(self.world, "axioms") else 50 - for birth in births: - newborn = Agent.create( - name=birth["name"], - personality="A newborn in this world, driven by local needs and social learning.", - llm=template_llm, - memory_size=memory_size, - ) - newborn.id = birth["id"] - self.world.register_agent( - newborn.id, - newborn.name, - location=birth["location"], - age_ticks=0, - vitality=100, - immune_resilience=50, - stress=0, - contagion_profile={"status": "susceptible", "infectious": False}, - ) - self.agents.append(newborn) - - def _materialize_recruited_agents(self) -> None: - recruits = self.world.consume_pending_recruits() - if not recruits: - return - - template_llm = self._template_llm or (self.agents[0].llm if self.agents else None) - if template_llm is None: - return - - memory_size = self.world.axioms.memory_size if hasattr(self.world, "axioms") else 50 - for recruit in recruits: - agent = Agent.create( - name=recruit["name"], - personality=recruit.get("personality", "A new collaborator in this company."), - llm=template_llm, - memory_size=memory_size, - ) - agent.id = recruit["id"] - self.world.register_agent( - agent.id, - agent.name, - location=recruit.get("location"), - inventory=dict(recruit.get("inventory", {})), - role_claims=[str(claim) for claim in recruit.get("role_claims", [])], - ) - self.agents.append(agent) diff --git a/antelab/engine/types.py b/antelab/engine/types.py deleted file mode 100644 index e8e661a..0000000 --- a/antelab/engine/types.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Core data types for the simulation engine.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass(frozen=True) -class Perception: - """What an agent observes about the world at this tick. - - Perception is local (Constitution Art. III): only co-located agents and events. - """ - - tick: int - location: str - nearby_agents: list[str] - nearby_items: list[str] - recent_events: list[str] - inventory: dict[str, int] = field(default_factory=dict) - self_status: dict[str, Any] = field(default_factory=dict) - environment: dict[str, Any] = field(default_factory=dict) - local_resources: dict[str, int] = field(default_factory=dict) - local_features: dict[str, int] = field(default_factory=dict) - company_context: dict[str, Any] = field(default_factory=dict) - recipes_known: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class Action: - """A free-form intent expressed by an agent (Constitution Art. IV). - - The agent specifies a verb and parameters. The World resolves it - against physical primitives — there is no fixed action menu. - """ - - agent_id: str - verb: str - parameters: dict[str, Any] = field(default_factory=dict) - reasoning: str = "" - - -@dataclass -class ActionResult: - """Outcome after the world resolves an intent against physics.""" - - success: bool - description: str - events: list[str] = field(default_factory=list) - state_changes: dict[str, Any] = field(default_factory=dict) - # Optional hints for passive observers (e.g. peer agent id on give/take). - observer_meta: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class Recipe: - """Resource transformation recipe for craft primitive.""" - - inputs: dict[str, int] = field(default_factory=dict) - outputs: dict[str, int] = field(default_factory=dict) - - -@dataclass -class ExperimentAxioms: - """Tunable physics for civilization experiments.""" - - perception: str = "local" - communication: str = "colocated" - social_tracking: bool = True - auto_eat: bool = False - mortality: bool = True - memory_size: int = 50 - - -@dataclass -class LifecycleParams: - """Deterministic lifecycle and health progression controls.""" - - age_tick_step: int = 1 - life_stage_thresholds: dict[str, int] = field( - default_factory=lambda: { - "juvenile": 20, - "adult": 60, - "elder": 360, - } - ) - vitality_loss_per_tick: int = 1 - vitality_rest_gain: int = 2 - stress_gain_per_tick: int = 1 - stress_rest_reduction: int = 2 - disease_exposure_threshold: int = 3 - disease_vitality_penalty: int = 2 - disease_stress_penalty: int = 1 - disease_recovery_ticks: int = 8 - disease_transmission_base_chance: float = 0.08 - disease_contact_weight: float = 0.22 - disease_exposure_weight: float = 0.1 - disease_resilience_protection_weight: float = 0.5 - disease_need_vulnerability_weight: float = 0.25 - disease_recovery_base_chance: float = 0.05 - disease_recovery_resilience_weight: float = 0.45 - disease_recovery_rest_bonus: float = 0.2 - disease_exposure_decay_per_tick: int = 1 - hunger_gain_per_tick: int = 1 - hunger_rest_reduction: int = 1 - fatigue_gain_per_tick: int = 1 - fatigue_rest_reduction: int = 3 - hunger_vitality_penalty_threshold: int = 70 - fatigue_stress_penalty_threshold: int = 70 - needs_penalty: int = 2 - auto_eat_hunger_threshold: int = 50 - nourishment_gain_per_food: int = 20 - food_items: tuple[str, ...] = ("bread", "apple") - conception_base_chance: float = 0.06 - conception_vitality_weight: float = 0.45 - conception_stress_weight: float = 0.35 - conception_hunger_weight: float = 0.25 - conception_infection_penalty: float = 0.4 - conception_trust_weight: float = 0.15 - conception_obligation_weight: float = 0.08 - conception_min_vitality: int = 55 - conception_max_stress: int = 60 - pregnancy_duration_min_ticks: int = 16 - pregnancy_duration_max_ticks: int = 32 - social_memory_max_entries: int = 64 - - -@dataclass -class PressureRuntime: - """Runtime switches for long-run world pressure systems.""" - - survival_enabled: bool = True - resources_enabled: bool = True - disease_enabled: bool = True - environment_enabled: bool = False - resource_decay_every: int = 0 - resource_regeneration_every: int = 0 - storage_decay_multiplier: float = 0.25 - season_length_ticks: int = 500 - - -@dataclass -class CompanyDemand: - """A physically satisfiable customer request in a company scenario.""" - - request_id: str - description: str = "" - required_item: str = "" - reward: int = 0 - deadline_tick: int = 0 - status: str = "open" # "open" | "delivered" | "missed" - - -@dataclass -class CompanyCandidate: - """A physically available collaborator candidate in a company scenario.""" - - candidate_id: str - name: str - personality: str = "" - location: str = "" - joining_cost: int = 0 - inventory: dict[str, int] = field(default_factory=dict) - role_claims: list[str] = field(default_factory=list) - status: str = "available" # "available" | "joined" - - -@dataclass -class CompanyArtifact: - """An in-world company knowledge object (report, charter, design, etc.).""" - - artifact_id: str - kind: str = "note" - title: str = "" - body: str = "" - created_by: str = "" - updated_by: str = "" - location: str = "" - tick_created: int = 0 - tick_updated: int = 0 - revision: int = 1 - - -@dataclass -class CompanyState: - """Company pressure state for founder-company experiments.""" - - enabled: bool = False - name: str = "" - stage: str = "founder" - cash: int = 0 - operating_cost_per_tick: int = 0 - demand_streams: list[CompanyDemand] = field(default_factory=list) - candidate_pool: list[CompanyCandidate] = field(default_factory=list) - artifacts: list[CompanyArtifact] = field(default_factory=list) - artifact_read_count: int = 0 - artifact_write_count: int = 0 - survival_gauntlet: dict[str, Any] = field(default_factory=dict) - gauntlet_applied_shocks: list[str] = field(default_factory=list) - pending_suggestions: list[dict[str, Any]] = field(default_factory=list) - org: Any = None # OrganizationState — lazy import to avoid circular deps - - def __post_init__(self) -> None: - if self.org is None: - from antelab.engine.org import OrganizationState - self.org = OrganizationState() - - -@dataclass -class EventVisibility: - """Locations where an event was physically observable.""" - - locations: set[str] = field(default_factory=set) - - -@dataclass -class AgentState: - """Public state of an agent as tracked by the world.""" - - id: str - name: str - location: str - inventory: dict[str, int] = field(default_factory=dict) - last_action: str | None = None - age_ticks: int = 0 - life_stage: str = "infant" - vitality: int = 100 - immune_resilience: int = 50 - stress: int = 0 - hunger: int = 0 - fatigue: int = 0 - pregnancy: dict[str, Any] | None = None - alive: bool = True - death_cause: str | None = None - contagion_profile: dict[str, Any] = field( - default_factory=lambda: { - "status": "susceptible", - "infectious": False, - "exposure_count": 0, - "infected_ticks": 0, - } - ) - trust_by_agent: dict[str, float] = field(default_factory=dict) - obligation_by_agent: dict[str, float] = field(default_factory=dict) - role_claims: list[str] = field(default_factory=list) diff --git a/antelab/engine/valuation.py b/antelab/engine/valuation.py deleted file mode 100644 index dc04ca5..0000000 --- a/antelab/engine/valuation.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Valuation model: company worth calculation and IPO condition checks. - -Viewer-only metrics. Agents perceive only cash and customer demands; -valuation and IPO progress are never included in agent perception. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class ValuationModel: - """Config-driven company valuation and IPO condition tracker.""" - - # Config (from company.yaml valuation section) - ipo_valuation_target: int = 10_000_000_000 # 10B default - ipo_consecutive_profitable_ticks: int = 5 - ipo_min_team_size: int = 5 - ipo_min_departments: int = 1 - weights: dict[str, float] = field( - default_factory=lambda: { - "cumulative_revenue": 10.0, - "revenue_growth_rate": 100.0, - "team_size": 5.0, - "org_complexity": 2.0, - "demand_completion_rate": 50.0, - "cash_reserve": 2.0, - } - ) - growth_rate_window_ticks: int = 10 - - # Runtime state - current_valuation: float = 0.0 - consecutive_profitable_ticks: int = 0 - ipo_triggered: bool = False - valuation_history: list[dict[str, Any]] = field(default_factory=list) - - # Internal tracking - _revenue_history: list[int] = field(default_factory=list) - _completion_history: list[bool] = field(default_factory=list) - - def calculate( - self, - cumulative_revenue: int, - team_size: int, - department_count: int, - demand_completion_rate: float, - cash_reserve: int, - ) -> float: - """Calculate current valuation using weighted formula. - - Each input metric is normalized to 0-1 before weighting. - revenue_growth_rate is derived from internal revenue history. - org_complexity is derived from department_count. - """ - w = self.weights - - # Normalize each metric to a 0-1 range. - rev_norm = min(1.0, cumulative_revenue / 10000.0) - growth_norm = min(1.0, self._revenue_growth_rate() / 2.0) - team_norm = min(1.0, team_size / 50.0) - org_norm = min(1.0, department_count / 10.0) - completion_norm = max(0.0, min(1.0, demand_completion_rate)) - cash_norm = min(1.0, cash_reserve / 10000.0) - - valuation = ( - w.get("cumulative_revenue", 1.0) * rev_norm - + w.get("revenue_growth_rate", 1.0) * growth_norm - + w.get("team_size", 1.0) * team_norm - + w.get("org_complexity", 1.0) * org_norm - + w.get("demand_completion_rate", 1.0) * completion_norm - + w.get("cash_reserve", 1.0) * cash_norm - ) - return round(valuation, 4) - - def _revenue_growth_rate(self) -> float: - """Compute revenue growth rate from recent history.""" - window = self.growth_rate_window_ticks or 10 - revs = self._revenue_history - if len(revs) < window + 1: - return 0.0 - recent = revs[-window:] - prior = revs[-(window * 2):-window] - if not prior or sum(prior) == 0: - return 0.0 - recent_avg = sum(recent) / len(recent) - prior_avg = sum(prior) / len(prior) - if prior_avg == 0: - return 0.0 - return (recent_avg - prior_avg) / prior_avg - - def record_revenue(self, amount: int) -> None: - """Record revenue from a single delivery.""" - self._revenue_history.append(amount) - - def record_completion(self, completed: bool) -> None: - """Record whether a tick was profitable (delivery rewarded).""" - self._completion_history.append(completed) - - def check_ipo(self, team_size: int, department_count: int) -> bool: - """Check whether IPO conditions are met. Returns True on IPO trigger.""" - if self.ipo_triggered: - return False - if self.current_valuation < self.ipo_valuation_target: - return False - if self.consecutive_profitable_ticks < self.ipo_consecutive_profitable_ticks: - return False - if team_size < self.ipo_min_team_size: - return False - if department_count < self.ipo_min_departments: - return False - self.ipo_triggered = True - return True - - def update( - self, - cumulative_revenue: int, - team_size: int, - department_count: int, - cash_reserve: int, - tick: int, - ) -> dict[str, Any]: - """Run a full valuation update for the current tick. - - Returns a dict with valuation, ipo_triggered, ipo_progress (0-1). - """ - # Demand completion rate from recent completion records. - hist = self._completion_history - completions = hist[-20:] if hist else [] - completion_rate = ( - sum(1 for c in completions if c) / len(completions) if completions else 0.0 - ) - - self.current_valuation = self.calculate( - cumulative_revenue=cumulative_revenue, - team_size=team_size, - department_count=department_count, - demand_completion_rate=completion_rate, - cash_reserve=cash_reserve, - ) - - # Track consecutive profitable ticks (a tick is profitable if revenue - # increased since last check, or if there's positive cash flow). - last_rev = self._revenue_history[-1] if self._revenue_history else 0 - if last_rev > 0: - self.consecutive_profitable_ticks += 1 - else: - self.consecutive_profitable_ticks = 0 - - ipo_now = self.check_ipo(team_size=team_size, department_count=department_count) - progress = min(1.0, self.current_valuation / self.ipo_valuation_target) - - entry = { - "tick": tick, - "valuation": self.current_valuation, - "ipo_triggered": ipo_now, - "ipo_progress": round(progress, 6), - "consecutive_profitable_ticks": self.consecutive_profitable_ticks, - } - self.valuation_history.append(entry) - return entry diff --git a/antelab/engine/world.py b/antelab/engine/world.py deleted file mode 100644 index a7d0c8b..0000000 --- a/antelab/engine/world.py +++ /dev/null @@ -1,2818 +0,0 @@ -"""World: the shared physical substrate of the simulation. - -The World is a physics engine (Constitution Art. IV, IX). -It resolves free-form agent intents against physical primitives. -It never makes moral judgments — only checks physical possibility. -""" - -from __future__ import annotations - -import random -import re -import uuid -from collections.abc import Iterable -from dataclasses import dataclass, field -from typing import Any - -from antelab.engine.market import MarketState -from antelab.engine.pattern import PatternDetector -from antelab.engine.space import OfficeSpace -from antelab.engine.types import ( - Action, - ActionResult, - AgentState, - CompanyArtifact, - CompanyCandidate, - CompanyDemand, - CompanyState, - EventVisibility, - ExperimentAxioms, - LifecycleParams, - PressureRuntime, - Recipe, -) -from antelab.engine.valuation import ValuationModel - - -@dataclass -class World: - """Holds all simulation state and resolves actions against physics.""" - - name: str - tick: int = 0 - locations: list[str] = field(default_factory=list) - location_graph: dict[str, list[str]] = field(default_factory=dict) - agents: dict[str, AgentState] = field(default_factory=dict) - event_log: list[str] = field(default_factory=list) - location_items: dict[str, dict[str, int]] = field(default_factory=dict) - location_features: dict[str, dict[str, int]] = field(default_factory=dict) - resource_zones: dict[str, dict[str, dict[str, int]]] = field(default_factory=dict) - recipes: dict[str, Recipe] = field(default_factory=dict) - company: CompanyState | dict[str, Any] | None = None - market: MarketState | None = None - pattern_detector: PatternDetector | None = None - valuation: ValuationModel | None = None - office_space: OfficeSpace | None = None - event_log_limit: int = 2000 - lifecycle: LifecycleParams = field(default_factory=LifecycleParams) - axioms: ExperimentAxioms = field(default_factory=ExperimentAxioms) - pressure: PressureRuntime | dict[str, Any] = field(default_factory=PressureRuntime) - environment: dict[str, Any] = field( - default_factory=lambda: { - "season": "spring", - "weather": "clear", - "disaster": None, - } - ) - pending_births: list[dict[str, str]] = field(default_factory=list) - pending_recruits: list[dict[str, Any]] = field(default_factory=list) - total_births: int = 0 - total_deaths: int = 0 - # location -> agent ids at that location (alive agents only; updated on register/move/death) - _location_index: dict[str, set[str]] = field(default_factory=dict, repr=False, init=False) - # Cache (global perception): rebuilt when world.tick changes - _alive_pairs_tick: int | None = field(default=None, repr=False, init=False) - _alive_pairs: list[tuple[str, str]] = field(default_factory=list, repr=False, init=False) - _event_meta: list[EventVisibility] = field(default_factory=list, repr=False, init=False) - - def __post_init__(self) -> None: - self.event_log_limit = max(1, int(self.event_log_limit)) - if isinstance(self.pressure, dict): - resources = self.pressure.get("resources", {}) - environment = self.pressure.get("environment", {}) - self.pressure = PressureRuntime( - survival_enabled=bool( - self.pressure.get("survival", {}).get("enabled", True) - ), - resources_enabled=bool(resources.get("enabled", True)), - disease_enabled=bool( - self.pressure.get("disease", {}).get("enabled", True) - ), - environment_enabled=bool(environment.get("enabled", False)), - resource_decay_every=int(resources.get("decay_every", 0)), - resource_regeneration_every=int( - resources.get("regeneration_every", 0) - ), - storage_decay_multiplier=float( - resources.get("storage_decay_multiplier", 0.25) - ), - season_length_ticks=int(environment.get("season_length_ticks", 500)), - ) - if self.company is None: - self.company = CompanyState() - elif isinstance(self.company, dict): - self.company = CompanyState( - enabled=bool(self.company.get("enabled", False)), - name=str(self.company.get("name", "")), - stage=str(self.company.get("stage", "founder")), - cash=max(0, int(self.company.get("cash", 0))), - operating_cost_per_tick=max( - 0, int(self.company.get("operating_cost_per_tick", 0)) - ), - demand_streams=[ - CompanyDemand( - request_id=str(raw.get("request_id", "")), - description=str(raw.get("description", "")), - required_item=str(raw.get("required_item", "")), - reward=max(0, int(raw.get("reward", 0))), - deadline_tick=max(0, int(raw.get("deadline_tick", 0))), - status=str(raw.get("status", "open")), - ) - for raw in self.company.get("demand_streams", []) - if isinstance(raw, dict) - ], - candidate_pool=[ - CompanyCandidate( - candidate_id=str(raw.get("candidate_id", "")), - name=str(raw.get("name", "")), - personality=str(raw.get("personality", "")), - location=str(raw.get("location", "")), - joining_cost=max(0, int(raw.get("joining_cost", 0))), - inventory={ - str(item): int(qty) - for item, qty in dict(raw.get("inventory", {})).items() - }, - role_claims=[ - str(claim) for claim in raw.get("role_claims", []) - ], - status=str(raw.get("status", "available")), - ) - for raw in self.company.get("candidate_pool", []) - if isinstance(raw, dict) - ], - artifacts=[ - CompanyArtifact( - artifact_id=str(raw.get("artifact_id", "")), - kind=str(raw.get("kind", "note")), - title=str(raw.get("title", "")), - body=str(raw.get("body", "")), - created_by=str(raw.get("created_by", "")), - updated_by=str(raw.get("updated_by", "")), - location=str(raw.get("location", "")), - tick_created=int(raw.get("tick_created", 0)), - tick_updated=int(raw.get("tick_updated", 0)), - revision=max(1, int(raw.get("revision", 1))), - ) - for raw in self.company.get("artifacts", []) - if isinstance(raw, dict) - ], - artifact_read_count=max(0, int(self.company.get("artifact_read_count", 0))), - artifact_write_count=max(0, int(self.company.get("artifact_write_count", 0))), - survival_gauntlet=dict(self.company.get("survival_gauntlet", {})), - gauntlet_applied_shocks=[ - str(shock_id) - for shock_id in self.company.get("gauntlet_applied_shocks", []) - ], - ) - # Initialize new company emergence modules from embedded config. - self._init_emergence_modules() - if not self.location_graph: - self.location_graph = { - location: [other for other in self.locations if other != location] - for location in self.locations - } - else: - normalized: dict[str, list[str]] = {} - known_locations = set(self.locations) - for location in self.locations: - raw_neighbors = self.location_graph.get(location, []) - neighbors = [ - neighbor - for neighbor in raw_neighbors - if neighbor in known_locations and neighbor != location - ] - normalized[location] = list(dict.fromkeys(neighbors)) - self.location_graph = normalized - - normalized_recipes: dict[str, Recipe] = {} - for name, recipe in self.recipes.items(): - if isinstance(recipe, Recipe): - normalized_recipes[name] = recipe - continue - if isinstance(recipe, dict): - normalized_recipes[name] = Recipe( - inputs=dict(recipe.get("inputs", {})), - outputs=dict(recipe.get("outputs", {})), - ) - self.recipes = normalized_recipes - - def _init_emergence_modules(self) -> None: - """Initialize company emergence modules from embedded config or defaults.""" - company = self.company - if not isinstance(company, CompanyState) or not company.enabled: - return - - # Extract raw config dict if available (before normalization). - raw_company: dict[str, Any] = {} - market_raw = raw_company.get("market", {}) - pattern_raw = raw_company.get("pattern", {}) - valuation_raw = raw_company.get("valuation", {}) - space_raw = raw_company.get("space", {}) - - self.market = MarketState( - demand_difficulty_by_stage={ - str(k): int(v) for k, v in market_raw.get( - "demand_difficulty_by_stage", - MarketState().demand_difficulty_by_stage, - ).items() - }, - max_open_demands=int(market_raw.get("max_open_demands", 5)), - demand_generation_every=int(market_raw.get("demand_generation_every", 3)), - reward_base={ - str(k): int(v) for k, v in market_raw.get( - "reward_base", MarketState().reward_base, - ).items() - }, - reward_spread=float(market_raw.get("reward_spread", 0.5)), - deadline_ticks_base={ - str(k): int(v) for k, v in market_raw.get( - "deadline_ticks_base", MarketState().deadline_ticks_base, - ).items() - }, - shock_pool={ - str(k): [dict(s) for s in v] - for k, v in market_raw.get("shocks", {}).items() - if isinstance(v, list) - }, - ) - - self.pattern_detector = PatternDetector( - window_ticks=int(pattern_raw.get("window_ticks", 20)), - threshold=int(pattern_raw.get("threshold", 5)), - suggestion_expiry_ticks=int(pattern_raw.get("suggestion_expiry_ticks", 5)), - ) - - self.valuation = ValuationModel( - ipo_valuation_target=int( - valuation_raw.get("ipo_valuation_target", 10_000_000_000) - ), - ipo_consecutive_profitable_ticks=int( - valuation_raw.get("ipo_consecutive_profitable_ticks", 5) - ), - ipo_min_team_size=int(valuation_raw.get("ipo_min_team_size", 5)), - ipo_min_departments=int(valuation_raw.get("ipo_min_departments", 1)), - weights={ - str(k): float(v) - for k, v in valuation_raw.get("weights", {}).items() - }, - growth_rate_window_ticks=int( - valuation_raw.get("growth_rate_window_ticks", 10) - ), - ) - - cap = { - str(k): int(v) - for k, v in space_raw.get( - "capacity_by_stage", OfficeSpace().expansion_cost, - ).items() - } - self.office_space = OfficeSpace( - stage=str(space_raw.get("stage", "garage")), - capacity=int(space_raw.get("capacity", cap.get("garage", 3))), - expansion_cost={ - str(k): int(v) - for k, v in space_raw.get( - "expansion_cost_by_stage", OfficeSpace().expansion_cost, - ).items() - }, - capacity_pressure_ticks=int(space_raw.get("capacity_pressure_ticks", 3)), - stress_per_overcapacity=int(space_raw.get("stress_per_overcapacity", 2)), - ) - - def _location_index_add(self, agent_id: str, location: str) -> None: - self._location_index.setdefault(location, set()).add(agent_id) - - def _location_index_remove(self, agent_id: str, location: str) -> None: - bucket = self._location_index.get(location) - if not bucket: - return - bucket.discard(agent_id) - if not bucket: - del self._location_index[location] - - def _location_index_move(self, agent_id: str, old_loc: str, new_loc: str) -> None: - if old_loc == new_loc: - return - self._location_index_remove(agent_id, old_loc) - self._location_index_add(agent_id, new_loc) - - def _sync_alive_pair_cache(self) -> None: - if self._alive_pairs_tick == self.tick: - return - self._alive_pairs_tick = self.tick - self._alive_pairs = [ - (a.id, a.name) - for a in sorted(self.agents.values(), key=lambda s: s.id) - if a.alive - ] - - def register_agent( - self, - agent_id: str, - name: str, - location: str | None = None, - inventory: dict[str, int] | None = None, - age_ticks: int = 0, - vitality: int = 100, - immune_resilience: int = 50, - stress: int = 0, - hunger: int = 0, - fatigue: int = 0, - pregnancy: dict[str, Any] | None = None, - contagion_profile: dict[str, Any] | None = None, - role_claims: list[str] | None = None, - alive: bool = True, - ) -> None: - """Add an agent to the world.""" - loc = location or (self.locations[0] if self.locations else "unknown") - profile = { - "status": "susceptible", - "infectious": False, - "exposure_count": 0, - "infected_ticks": 0, - } - if contagion_profile is not None: - profile.update(contagion_profile) - prior = self.agents.get(agent_id) - if prior is not None: - self._location_index_remove(agent_id, prior.location) - self.agents[agent_id] = AgentState( - id=agent_id, - name=name, - location=loc, - inventory=inventory or {}, - age_ticks=age_ticks, - life_stage=self._life_stage_for_age(age_ticks), - vitality=max(0, min(100, vitality)), - immune_resilience=max(0, min(100, immune_resilience)), - stress=max(0, min(100, stress)), - hunger=max(0, min(100, hunger)), - fatigue=max(0, min(100, fatigue)), - pregnancy=dict(pregnancy) if pregnancy else None, - contagion_profile=profile, - role_claims=list(role_claims or []), - alive=alive, - ) - if alive: - self._location_index_add(agent_id, loc) - - def get_nearby_agents(self, agent_id: str) -> list[str]: - """Return names of agents visible to this agent. - - Respects the perception axiom: "local" restricts to co-located agents, - "global" exposes all living agents regardless of location. - """ - agent = self.agents.get(agent_id) - if not agent or not agent.alive: - return [] - if self.axioms.perception == "global": - self._sync_alive_pair_cache() - return [name for aid, name in self._alive_pairs if aid != agent_id] - names: list[str] = [] - for peer_id in sorted(self._location_index.get(agent.location, ())): - if peer_id == agent_id: - continue - peer = self.agents.get(peer_id) - if peer and peer.alive: - names.append(peer.name) - return names - - def get_nearby_items(self, agent_id: str) -> list[str]: - """Return items at the agent's current location.""" - agent = self.agents.get(agent_id) - if not agent or not agent.alive: - return [] - items = self.location_items.get(agent.location, {}) - return [f"{name}x{qty}" for name, qty in items.items() if qty > 0] - - def get_recent_events(self, n: int = 10, location: str | None = None) -> list[str]: - """Return the last N events.""" - if location is None: - return self.event_log[-n:] - - recent_events = self.event_log[-n:] - recent_meta = self._aligned_event_meta()[-n:] - return [ - event - for event, meta in zip(recent_events, recent_meta, strict=False) - if location in meta.locations - ] - - # --- Physics engine: resolve free-form intents --- - - def apply_action(self, action: Action) -> ActionResult: - """Resolve a free-form agent intent against physical primitives. - - Maps the verb to a physical primitive. Unknown verbs are not rejected - as "invalid" — they're rejected as "physically impossible" if they - don't correspond to anything the physics engine can execute. - """ - agent = self.agents.get(action.agent_id) - if not agent: - return ActionResult( - success=False, - description=f"Unknown agent: {action.agent_id}", - ) - if not agent.alive: - return ActionResult( - success=False, - description=f"{agent.name} is dead and cannot act", - ) - - verb = action.verb.lower().strip() - - # Track action for pattern detection (company emergence). - if self.pattern_detector is not None: - self.pattern_detector.track_action_at_tick( - action.agent_id, self.tick, verb, action.parameters, - ) - - resolver = _PRIMITIVES.get(verb) - if resolver is not None: - result = resolver(self, agent, action) - # Track delivery revenues for valuation. - if result.success and self.valuation is not None and verb in ("deliver", "ship"): - params = action.parameters or {} - demand_id = params.get("request_id", "") - demand = self._find_open_company_demand(str(demand_id)) - reward = demand.reward if demand else 0 - if reward: - self.valuation.record_revenue(reward) - self.valuation.record_completion(True) - elif result.success and self.valuation is not None: - self.valuation.record_completion(False) - return result - - return self._resolve_unknown(agent, action) - - def advance_tick(self) -> None: - """Move the simulation forward by one tick.""" - self._apply_lifecycle_updates() - self._trim_event_log() - self.tick += 1 - self._apply_company_gauntlet_shocks() - self._apply_company_pressure() - self._apply_market_update() - self._apply_pattern_detection() - self._apply_space_pressure() - self._apply_valuation_update() - self._apply_resource_decay() - self._apply_resource_regeneration() - self._apply_environment_updates() - - def to_dict(self) -> dict[str, Any]: - """Serialize world state for API responses.""" - alive_agents = [a for a in self.agents.values() if a.alive] - infected_agents = [ - a for a in alive_agents if bool(a.contagion_profile.get("infectious", False)) - ] - age_distribution = { - "infant": 0, - "juvenile": 0, - "adult": 0, - "elder": 0, - } - total_hunger = 0.0 - total_fatigue = 0.0 - for agent in alive_agents: - age_distribution[agent.life_stage] = age_distribution.get(agent.life_stage, 0) + 1 - total_hunger += agent.hunger - total_fatigue += agent.fatigue - n_alive = len(alive_agents) - avg_hunger = total_hunger / n_alive if n_alive else 0.0 - avg_fatigue = total_fatigue / n_alive if n_alive else 0.0 - event_log = [ - { - "event": event, - "visibility": {"locations": sorted(meta.locations)}, - } - for event, meta in zip( - self.event_log, - self._aligned_event_meta(), - strict=False, - ) - ] - - company_summary = self._company_summary() - return { - "name": self.name, - "tick": self.tick, - "locations": self.locations, - "location_graph": self.location_graph, - "location_items": self.location_items, - "location_features": self.location_features, - "resource_zones": self.resource_zones, - "environment": dict(self.environment), - "event_log_limit": self.event_log_limit, - "experiment": { - "perception": self.axioms.perception, - "communication": self.axioms.communication, - "social_tracking": self.axioms.social_tracking, - "auto_eat": self.axioms.auto_eat, - "mortality": self.axioms.mortality, - "memory_size": self.axioms.memory_size, - }, - "pressure": { - "survival": { - "enabled": self.pressure.survival_enabled, - }, - "resources": { - "enabled": self.pressure.resources_enabled, - "decay_every": self.pressure.resource_decay_every, - "regeneration_every": self.pressure.resource_regeneration_every, - "storage_decay_multiplier": self.pressure.storage_decay_multiplier, - }, - "disease": { - "enabled": self.pressure.disease_enabled, - }, - "environment": { - "enabled": self.pressure.environment_enabled, - "season_length_ticks": self.pressure.season_length_ticks, - }, - }, - "metrics": { - "alive_count": len(alive_agents), - "total_births": self.total_births, - "total_deaths": self.total_deaths, - "birth_rate": self.total_births / max(1, self.tick + 1), - "death_rate": self.total_deaths / max(1, self.tick + 1), - "age_distribution": age_distribution, - "active_outbreak_estimate": len(infected_agents), - "average_hunger": avg_hunger, - "average_fatigue": avg_fatigue, - }, - "company": company_summary, - "recipes": { - name: { - "inputs": recipe.inputs, - "outputs": recipe.outputs, - } - for name, recipe in self.recipes.items() - }, - "agents": [ - { - "id": a.id, - "name": a.name, - "location": a.location, - "inventory": a.inventory, - "last_action": a.last_action, - "age_ticks": a.age_ticks, - "life_stage": a.life_stage, - "vitality": a.vitality, - "immune_resilience": a.immune_resilience, - "stress": a.stress, - "hunger": a.hunger, - "fatigue": a.fatigue, - "pregnancy": a.pregnancy, - "alive": a.alive, - "death_cause": a.death_cause, - "contagion_profile": a.contagion_profile, - "role_claims": list(a.role_claims), - } - for a in self.agents.values() - ], - "event_log": event_log, - "recent_events": self.get_recent_events(), - } - - # --- Physical primitives --- - - def _resolve_move(self, agent: AgentState, action: Action) -> ActionResult: - destination = action.parameters.get("destination", "") - if destination not in self.locations: - return ActionResult( - success=False, - description=f"{agent.name} cannot move to '{destination}' — no such place", - ) - if not self._is_adjacent(agent.location, destination): - return ActionResult( - success=False, - description=( - f"{agent.name} cannot move from {agent.location} to {destination} " - "— destination is not adjacent" - ), - ) - old = agent.location - agent.location = destination - self._location_index_move(agent.id, old, destination) - event = f"[Tick {self.tick}] {agent.name} moved from {old} to {destination}" - self._append_event(event, locations=[old, destination]) - agent.last_action = f"moved to {destination}" - return ActionResult( - success=True, description=event, events=[event], - state_changes={"location": destination}, - ) - - def _resolve_say(self, agent: AgentState, action: Action) -> ActionResult: - if self.axioms.communication == "silent": - event = ( - f"[Tick {self.tick}] {agent.name} tried to speak " - f"— communication is not possible in this world" - ) - self._append_event(event, location=agent.location) - agent.last_action = "attempted: say (silent world)" - return ActionResult(success=False, description=event, events=[event]) - message = action.parameters.get("message", "...") - event = f'[Tick {self.tick}] {agent.name} says: "{message}"' - self._append_event(event, location=agent.location) - agent.last_action = f"said: {message}" - return ActionResult(success=True, description=event, events=[event]) - - def _resolve_give(self, agent: AgentState, action: Action) -> ActionResult: - target_name = action.parameters.get("target", "") - item = action.parameters.get("item", "") - qty = int(action.parameters.get("quantity", 1)) - - target = self._find_agent_by_name(target_name, agent.location) - if target is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot give to '{target_name}' — not present", - ) - if agent.inventory.get(item, 0) < qty: - return ActionResult( - success=False, - description=f"{agent.name} does not have {qty} {item} to give", - ) - - source_before = agent.inventory.get(item, 0) - target_before = target.inventory.get(item, 0) - agent.inventory[item] = agent.inventory.get(item, 0) - qty - target.inventory[item] = target.inventory.get(item, 0) + qty - if self.axioms.social_tracking: - agent.trust_by_agent[target.id] = agent.trust_by_agent.get(target.id, 0.0) + 0.05 - target.obligation_by_agent[agent.id] = ( - target.obligation_by_agent.get(agent.id, 0.0) + (0.1 * qty) - ) - event = f"[Tick {self.tick}] {agent.name} gave {qty} {item} to {target.name}" - self._append_event(event, location=agent.location) - agent.last_action = f"gave {qty} {item} to {target.name}" - return ActionResult( - success=True, - description=event, - events=[event], - observer_meta={ - "peer_agent_id": target.id, - "item": item, - "quantity": qty, - }, - state_changes={ - f"agent_inventory.{agent.id}.{item}": { - "before": source_before, - "after": agent.inventory.get(item, 0), - }, - f"agent_inventory.{target.id}.{item}": { - "before": target_before, - "after": target.inventory.get(item, 0), - }, - }, - ) - - def _resolve_take(self, agent: AgentState, action: Action) -> ActionResult: - source_name = action.parameters.get("target", action.parameters.get("source", "")) - item = action.parameters.get("item", "") - qty = int(action.parameters.get("quantity", 1)) - - source = self._find_agent_by_name(source_name, agent.location) - if source is not None: - if source.inventory.get(item, 0) < qty: - return ActionResult( - success=False, - description=f"{source.name} does not have {qty} {item}", - ) - source_before = source.inventory.get(item, 0) - agent_before = agent.inventory.get(item, 0) - source.inventory[item] = source.inventory.get(item, 0) - qty - agent.inventory[item] = agent.inventory.get(item, 0) + qty - if self.axioms.social_tracking: - source.trust_by_agent[agent.id] = source.trust_by_agent.get(agent.id, 0.0) - ( - 0.08 * qty - ) - agent.obligation_by_agent[source.id] = ( - agent.obligation_by_agent.get(source.id, 0.0) + (0.15 * qty) - ) - event = f"[Tick {self.tick}] {agent.name} took {qty} {item} from {source.name}" - self._append_event(event, location=agent.location) - agent.last_action = f"took {qty} {item} from {source.name}" - return ActionResult( - success=True, - description=event, - events=[event], - observer_meta={ - "peer_agent_id": source.id, - "item": item, - "quantity": qty, - }, - state_changes={ - f"agent_inventory.{source.id}.{item}": { - "before": source_before, - "after": source.inventory.get(item, 0), - }, - f"agent_inventory.{agent.id}.{item}": { - "before": agent_before, - "after": agent.inventory.get(item, 0), - }, - }, - ) - - loc_items = self.location_items.get(agent.location, {}) - if loc_items.get(item, 0) >= qty: - location_before = loc_items.get(item, 0) - agent_before = agent.inventory.get(item, 0) - loc_items[item] = loc_items.get(item, 0) - qty - agent.inventory[item] = agent.inventory.get(item, 0) + qty - event = f"[Tick {self.tick}] {agent.name} picked up {qty} {item}" - self._append_event(event, location=agent.location) - agent.last_action = f"picked up {qty} {item}" - return ActionResult( - success=True, - description=event, - events=[event], - state_changes={ - f"location_items.{agent.location}.{item}": { - "before": location_before, - "after": loc_items.get(item, 0), - }, - f"agent_inventory.{agent.id}.{item}": { - "before": agent_before, - "after": agent.inventory.get(item, 0), - }, - }, - ) - - return ActionResult( - success=False, - description=f"{agent.name} cannot take {qty} {item} — not available here", - ) - - def _resolve_harvest(self, agent: AgentState, action: Action) -> ActionResult: - resource = str( - action.parameters.get("resource") or action.parameters.get("item") or "" - ).strip() - raw_quantity = action.parameters.get("quantity", 1) - try: - if isinstance(raw_quantity, bool): - raise ValueError - if isinstance(raw_quantity, int): - quantity = raw_quantity - elif isinstance(raw_quantity, str): - quantity = int(raw_quantity.strip()) - else: - quantity = int(raw_quantity) - if quantity != raw_quantity: - raise ValueError - except (TypeError, ValueError): - return ActionResult(False, "Harvest failed: invalid quantity.") - if quantity < 1: - return ActionResult(False, "Harvest failed: quantity must be positive.") - bucket = self.location_items.get(agent.location, {}) - if not resource or bucket.get(resource, 0) < quantity: - return ActionResult( - False, - f"Harvest failed: not enough {resource} at {agent.location}.", - ) - bucket[resource] -= quantity - if bucket[resource] <= 0: - del bucket[resource] - agent.inventory[resource] = agent.inventory.get(resource, 0) + quantity - event = ( - f"[Tick {self.tick}] {agent.name} harvested " - f"{quantity} {resource} at {agent.location}." - ) - self._append_event(event, location=agent.location) - agent.last_action = f"harvested {quantity} {resource} at {agent.location}" - return ActionResult( - True, - event, - events=[event], - state_changes={"harvested": {resource: quantity}}, - ) - - def _resolve_store(self, agent: AgentState, action: Action) -> ActionResult: - item = str(action.parameters.get("item", "")).strip() - raw_quantity = action.parameters.get("quantity", 1) - try: - if isinstance(raw_quantity, bool): - raise ValueError - if isinstance(raw_quantity, int): - quantity = raw_quantity - elif isinstance(raw_quantity, str): - quantity = int(raw_quantity.strip()) - else: - quantity = int(raw_quantity) - if quantity != raw_quantity: - raise ValueError - except (TypeError, ValueError): - return ActionResult(False, "Store failed: invalid quantity.") - if not item: - return ActionResult(False, "Store failed: missing item.") - if quantity < 1: - return ActionResult(False, "Store failed: quantity must be positive.") - if agent.inventory.get(item, 0) < quantity: - return ActionResult( - False, - f"Store failed: {agent.name} does not have enough {item}.", - ) - agent_before = agent.inventory.get(item, 0) - agent.inventory[item] -= quantity - if agent.inventory[item] <= 0: - del agent.inventory[item] - stored_item = f"stored:{item}" - bucket = self.location_items.setdefault(agent.location, {}) - stored_before = bucket.get(stored_item, 0) - bucket[stored_item] = bucket.get(stored_item, 0) + quantity - event = f"[Tick {self.tick}] {agent.name} stored {quantity} {item} at {agent.location}" - self._append_event(event, location=agent.location) - agent.last_action = f"stored {quantity} {item} at {agent.location}" - return ActionResult( - True, - event, - events=[event], - state_changes={ - "stored": {stored_item: quantity}, - f"agent_inventory.{agent.id}.{item}": { - "before": agent_before, - "after": agent.inventory.get(item, 0), - }, - f"location_items.{agent.location}.{stored_item}": { - "before": stored_before, - "after": bucket.get(stored_item, 0), - }, - }, - ) - - def _resolve_build_shelter(self, agent: AgentState, action: Action) -> ActionResult: - materials = action.parameters.get("materials", {"wood": 2}) - if not isinstance(materials, dict): - return ActionResult(False, "Build shelter failed: materials must be a mapping.") - if not materials: - return ActionResult(False, "Build shelter failed: materials must not be empty.") - - required_materials: dict[str, int] = {} - for item, qty_raw in materials.items(): - item_name = str(item).strip() - if not item_name: - return ActionResult(False, "Build shelter failed: missing material name.") - try: - if isinstance(qty_raw, bool): - raise ValueError - if isinstance(qty_raw, int): - qty = qty_raw - elif isinstance(qty_raw, str): - qty = int(qty_raw.strip()) - else: - qty = int(qty_raw) - if qty != qty_raw: - raise ValueError - except (TypeError, ValueError): - return ActionResult( - False, - f"Build shelter failed: invalid quantity for {item_name}.", - ) - if qty < 1: - return ActionResult( - False, - f"Build shelter failed: quantity for {item_name} must be positive.", - ) - required_materials[item_name] = qty - - for item, qty in required_materials.items(): - if agent.inventory.get(item, 0) < qty: - return ActionResult(False, f"Build shelter failed: missing {item}.") - - for item, qty in required_materials.items(): - agent.inventory[item] -= qty - if agent.inventory[item] <= 0: - del agent.inventory[item] - features = self.location_features.setdefault(agent.location, {}) - features["shelter"] = features.get("shelter", 0) + 1 - event = f"[Tick {self.tick}] {agent.name} built shelter at {agent.location}." - self._append_event(event, location=agent.location) - agent.last_action = f"built shelter at {agent.location}" - return ActionResult( - True, - event, - events=[event], - state_changes={"location_features": features}, - ) - - def _resolve_consume(self, agent: AgentState, action: Action) -> ActionResult: - item = str(action.parameters.get("item", "")).strip() - raw_quantity = action.parameters.get("quantity", 1) - try: - if isinstance(raw_quantity, bool): - raise ValueError - if isinstance(raw_quantity, int): - quantity = raw_quantity - elif isinstance(raw_quantity, str): - quantity = int(raw_quantity.strip()) - else: - quantity = int(raw_quantity) - if quantity != raw_quantity: - raise ValueError - except (TypeError, ValueError): - return ActionResult(False, "Consume failed: invalid quantity.") - if not item: - return ActionResult(False, "Consume failed: missing item.") - if quantity < 1: - return ActionResult(False, "Consume failed: quantity must be positive.") - if item not in self.lifecycle.food_items: - return ActionResult(False, f"Consume failed: {item} is not food.") - if agent.inventory.get(item, 0) < quantity: - return ActionResult( - False, - f"Consume failed: {agent.name} does not have enough {item}.", - ) - agent.inventory[item] -= quantity - if agent.inventory[item] <= 0: - del agent.inventory[item] - nourishment = self.lifecycle.nourishment_gain_per_food * quantity - agent.hunger = max(0, agent.hunger - nourishment) - event = f"[Tick {self.tick}] {agent.name} consumed {quantity} {item}." - self._append_event(event, location=agent.location) - agent.last_action = f"consumed {quantity} {item}" - return ActionResult( - True, - event, - events=[event], - state_changes={"hunger": agent.hunger}, - ) - - def _resolve_treat(self, agent: AgentState, action: Action) -> ActionResult: - target_name = str(action.parameters.get("target", "")).strip() - item = str(action.parameters.get("item", "")).strip() - if not item: - return ActionResult(False, "Treat failed: missing treatment item.") - if item != "medicine": - return ActionResult(False, f"Treat failed: {item} is not a treatment item.") - target = self._find_agent_by_name(target_name, agent.location) - if target is None: - return ActionResult(False, f"Treat failed: target {target_name} is not present.") - if agent.inventory.get(item, 0) < 1: - return ActionResult(False, f"Treat failed: {agent.name} lacks {item}.") - - agent.inventory[item] -= 1 - if agent.inventory[item] <= 0: - del agent.inventory[item] - profile = target.contagion_profile - profile["infected_ticks"] = max(0, int(profile.get("infected_ticks", 0)) - 2) - target.stress = max(0, target.stress - 5) - event = f"[Tick {self.tick}] {agent.name} treated {target.name} with {item}." - self._append_event(event, location=agent.location) - agent.last_action = f"treated {target.name} with {item}" - return ActionResult( - True, - event, - events=[event], - observer_meta={"peer_agent_id": target.id, "care_event": True}, - ) - - def _resolve_reproduce(self, agent: AgentState, action: Action) -> ActionResult: - target_name = str(action.parameters.get("target", "")).strip() - target = self._find_agent_by_name(target_name, agent.location) - if target is None: - return ActionResult( - False, - f"Reproduce failed: target {target_name} is not present.", - ) - if target.id == agent.id: - return ActionResult(False, "Reproduce failed: target cannot be self.") - - for participant in (agent, target): - blocker = self._conception_blocker(participant) - if blocker is not None: - return ActionResult(False, f"Reproduce failed: {blocker}.") - - if agent.pregnancy is not None: - return ActionResult( - False, - f"Reproduce failed: {agent.name} is already pregnant.", - ) - - chance = self._conception_chance(agent, target) - if random.random() >= chance: - event = ( - f"[Tick {self.tick}] {agent.name} and {target.name} attempted " - "reproduction; it did not result in pregnancy." - ) - self._append_event(event, location=agent.location) - agent.last_action = f"attempted reproduction with {target.name}" - return ActionResult( - True, - event, - events=[event], - observer_meta={"peer_agent_id": target.id}, - ) - - event = self._start_pregnancy(agent, target) - agent.last_action = f"started pregnancy with {target.name}" - return ActionResult( - True, - event, - events=[event], - state_changes={"pregnancy": agent.pregnancy}, - observer_meta={"peer_agent_id": target.id}, - ) - - def _resolve_examine(self, agent: AgentState, action: Action) -> ActionResult: - target_name = action.parameters.get("target", "") - target = self._find_agent_by_name(target_name, agent.location) - if target is not None: - info = ( - f"name={target.name}, location={target.location}, " - f"last_action={target.last_action}" - ) - event = f"[Tick {self.tick}] {agent.name} examined {target.name}: {info}" - self._append_event(event, location=agent.location) - agent.last_action = f"examined {target.name}" - return ActionResult( - success=True, description=event, events=[event], - state_changes={"observed": info}, - ) - return ActionResult( - success=False, - description=f"{agent.name} cannot examine '{target_name}' — not present", - ) - - def _resolve_rest(self, agent: AgentState, action: Action) -> ActionResult: - event = f"[Tick {self.tick}] {agent.name} rests" - self._append_event(event, location=agent.location) - agent.last_action = "rested" - return ActionResult(success=True, description=event, events=[event]) - - def _resolve_craft(self, agent: AgentState, action: Action) -> ActionResult: - recipe_name = str(action.parameters.get("recipe", "")).strip() - if not recipe_name: - recipe_name = str(action.parameters.get("item", "")).strip() - quantity = int(action.parameters.get("quantity", 1)) - if quantity < 1: - return ActionResult( - success=False, - description=f"{agent.name} cannot craft with quantity < 1", - ) - recipe = self.recipes.get(recipe_name) - if recipe is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot craft '{recipe_name}' — unknown recipe", - ) - - for item, required in recipe.inputs.items(): - required_total = required * quantity - if agent.inventory.get(item, 0) < required_total: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot craft '{recipe_name}' — insufficient {item} " - f"(need {required_total})" - ), - ) - - for item, required in recipe.inputs.items(): - required_total = required * quantity - agent.inventory[item] = agent.inventory.get(item, 0) - required_total - for item, produced in recipe.outputs.items(): - produced_total = produced * quantity - agent.inventory[item] = agent.inventory.get(item, 0) + produced_total - - event = f"[Tick {self.tick}] {agent.name} crafted {recipe_name} x{quantity}" - self._append_event(event, location=agent.location) - agent.last_action = f"crafted {recipe_name} x{quantity}" - return ActionResult( - success=True, - description=event, - events=[event], - ) - - def _resolve_deliver(self, agent: AgentState, action: Action) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot deliver — no company demand is active", - ) - - request_id = str(action.parameters.get("request_id", "")).strip() - demand = self._find_open_company_demand(request_id) - if demand is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot deliver request '{request_id}' — not open", - ) - - item = str(action.parameters.get("item", demand.required_item)).strip() - if item != demand.required_item: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot deliver {item} for {demand.request_id} " - f"— requires {demand.required_item}" - ), - ) - if agent.inventory.get(item, 0) < 1: - return ActionResult( - success=False, - description=f"{agent.name} cannot deliver {item} — not carried", - ) - - inventory_before = agent.inventory.get(item, 0) - cash_before = company.cash - demand_status_before = demand.status - agent.inventory[item] = agent.inventory.get(item, 0) - 1 - demand.status = "delivered" - company.cash += demand.reward - event = ( - f"[Tick {self.tick}] {agent.name} delivered {item} for {demand.request_id}; " - f"company earned {demand.reward} cash" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"delivered {item} for {demand.request_id}" - return ActionResult( - success=True, - description=event, - events=[event], - state_changes={ - "company_cash": company.cash, - "request_id": demand.request_id, - f"agent_inventory.{agent.id}.{item}": { - "before": inventory_before, - "after": agent.inventory.get(item, 0), - }, - "company.cash": { - "before": cash_before, - "after": company.cash, - }, - f"company.demand_streams.{demand.request_id}.status": { - "before": demand_status_before, - "after": demand.status, - }, - }, - ) - - def _resolve_recruit(self, agent: AgentState, action: Action) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot recruit — no company candidate pool is active", - ) - - candidate_id = str(action.parameters.get("candidate_id", "")).strip() - candidate = self._find_available_company_candidate(candidate_id) - if candidate is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot recruit '{candidate_id}' — not available", - ) - if company.cash < candidate.joining_cost: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot recruit {candidate.name} — insufficient company cash " - f"(need {candidate.joining_cost})" - ), - ) - - cash_before = company.cash - candidate_status_before = candidate.status - company.cash -= candidate.joining_cost - candidate.status = "joined" - recruit_id = candidate.candidate_id - self.pending_recruits.append( - { - "id": recruit_id, - "name": candidate.name, - "personality": candidate.personality, - "location": candidate.location or agent.location, - "inventory": dict(candidate.inventory), - "role_claims": list(candidate.role_claims), - } - ) - event = ( - f"[Tick {self.tick}] {agent.name} recruited {candidate.name}; " - f"company spent {candidate.joining_cost} cash" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"recruited {candidate.name}" - return ActionResult( - success=True, - description=event, - events=[event], - state_changes={ - "company_cash": company.cash, - "candidate_id": recruit_id, - "company.cash": { - "before": cash_before, - "after": company.cash, - }, - f"company.candidate_pool.{recruit_id}.status": { - "before": candidate_status_before, - "after": candidate.status, - }, - f"pending_recruits.{recruit_id}.exists": { - "before": False, - "after": True, - }, - }, - ) - - def _resolve_write_artifact(self, agent: AgentState, action: Action) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot write company artifact — " - "company mode is inactive" - ), - ) - artifact_id = str(action.parameters.get("artifact_id", "")).strip() - if not artifact_id: - artifact_id = str(uuid.uuid4())[:8] - artifact = CompanyArtifact( - artifact_id=artifact_id, - kind=str(action.parameters.get("kind", "note")).strip() or "note", - title=str(action.parameters.get("title", "Untitled artifact")).strip() - or "Untitled artifact", - body=str(action.parameters.get("body", "")), - created_by=agent.id, - updated_by=agent.id, - location=agent.location, - tick_created=self.tick, - tick_updated=self.tick, - revision=1, - ) - company.artifacts.append(artifact) - company.artifact_write_count += 1 - event = ( - f"[Tick {self.tick}] {agent.name} wrote company artifact " - f"{artifact.artifact_id}: {artifact.title}" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"wrote artifact {artifact.title}" - return ActionResult( - success=True, - description=event, - events=[event], - state_changes={ - "artifact_id": artifact.artifact_id, - "revision": artifact.revision, - f"company.artifacts.{artifact.artifact_id}.exists": { - "before": False, - "after": True, - }, - f"company.artifacts.{artifact.artifact_id}.revision": { - "before": 0, - "after": artifact.revision, - }, - }, - ) - - def _resolve_read_artifact(self, agent: AgentState, action: Action) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - artifact_id = str(action.parameters.get("artifact_id", "")).strip() - artifact = self._find_company_artifact(artifact_id) - if artifact is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot read artifact '{artifact_id}' — not found", - ) - if artifact.location != agent.location: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot read artifact '{artifact_id}' — not visible at " - f"{agent.location}" - ), - ) - company.artifact_read_count += 1 - event = ( - f"[Tick {self.tick}] {agent.name} read company artifact " - f"{artifact.artifact_id}: {artifact.title}" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"read artifact {artifact.title}" - return ActionResult( - success=True, - description=f"{event} — {artifact.body}", - events=[event], - state_changes={"artifact_id": artifact.artifact_id, "body": artifact.body}, - ) - - def _resolve_update_artifact(self, agent: AgentState, action: Action) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - artifact_id = str(action.parameters.get("artifact_id", "")).strip() - artifact = self._find_company_artifact(artifact_id) - if artifact is None: - return ActionResult( - success=False, - description=f"{agent.name} cannot update artifact '{artifact_id}' — not found", - ) - if artifact.location != agent.location: - return ActionResult( - success=False, - description=( - f"{agent.name} cannot update artifact '{artifact_id}' — not visible at " - f"{agent.location}" - ), - ) - revision_before = artifact.revision - body_before = artifact.body - if "title" in action.parameters: - artifact.title = ( - str(action.parameters.get("title", artifact.title)).strip() - or artifact.title - ) - if "kind" in action.parameters: - artifact.kind = ( - str(action.parameters.get("kind", artifact.kind)).strip() - or artifact.kind - ) - if "body" in action.parameters: - artifact.body = str(action.parameters.get("body", artifact.body)) - artifact.updated_by = agent.id - artifact.tick_updated = self.tick - artifact.revision += 1 - company.artifact_write_count += 1 - event = ( - f"[Tick {self.tick}] {agent.name} updated company artifact " - f"{artifact.artifact_id}: {artifact.title} rev {artifact.revision}" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"updated artifact {artifact.title}" - return ActionResult( - success=True, - description=event, - events=[event], - state_changes={ - "artifact_id": artifact.artifact_id, - "revision": artifact.revision, - f"company.artifacts.{artifact.artifact_id}.revision": { - "before": revision_before, - "after": artifact.revision, - }, - f"company.artifacts.{artifact.artifact_id}.body": { - "before": body_before, - "after": artifact.body, - }, - }, - ) - - def _resolve_accept_suggestion( - self, agent: AgentState, action: Action - ) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot accept suggestion — company mode inactive", - ) - suggestion_id = str(action.parameters.get("suggestion_id", "")) - suggestion = next( - (s for s in company.pending_suggestions - if s.get("suggestion_id") == suggestion_id), - None, - ) - if suggestion is None: - return ActionResult( - success=False, - description=f"{agent.name} tried to accept nonexistent suggestion {suggestion_id}", - ) - if suggestion.get("target_agent_id") != agent.id: - return ActionResult( - success=False, - description=f"{agent.name} cannot accept a suggestion not targeted at them", - ) - # Remove suggestion from pending. - company.pending_suggestions = [ - s for s in company.pending_suggestions - if s.get("suggestion_id") != suggestion_id - ] - category = suggestion.get("category", "") - dept_name = _suggestion_dept_name(category) - tick = self.tick - company.org.add_department( - name=dept_name, - lead_agent_id=agent.id, - member_agent_ids=[agent.id], - created_tick=tick, - suggestion_id=suggestion_id, - ) - event = ( - f"[Tick {tick}] {agent.name} accepted suggestion and formed " - f"{dept_name}" - ) - self._append_event(event, location=agent.location) - return ActionResult( - success=True, description=event, events=[event], - state_changes={"department_created": dept_name}, - ) - - def _resolve_modify_suggestion( - self, agent: AgentState, action: Action - ) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot modify suggestion — company mode inactive", - ) - suggestion_id = str(action.parameters.get("suggestion_id", "")) - suggestion = next( - (s for s in company.pending_suggestions - if s.get("suggestion_id") == suggestion_id), - None, - ) - if suggestion is None or suggestion.get("target_agent_id") != agent.id: - return ActionResult( - success=False, - description=f"{agent.name} cannot modify that suggestion", - ) - modifications = action.parameters.get("modifications", {}) - if not isinstance(modifications, dict): - return ActionResult( - success=False, - description=f"{agent.name} modifications must be a mapping", - ) - company.pending_suggestions = [ - s for s in company.pending_suggestions - if s.get("suggestion_id") != suggestion_id - ] - dept_name = str(modifications.get("name", _suggestion_dept_name( - suggestion.get("category", "") - ))) - tick = self.tick - company.org.add_department( - name=dept_name, - lead_agent_id=agent.id, - member_agent_ids=[agent.id], - created_tick=tick, - suggestion_id=suggestion_id, - ) - event = ( - f"[Tick {tick}] {agent.name} modified suggestion and formed " - f"{dept_name}" - ) - self._append_event(event, location=agent.location) - return ActionResult( - success=True, description=event, events=[event], - state_changes={"department_created": dept_name}, - ) - - def _resolve_create_role( - self, agent: AgentState, action: Action - ) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot create role — company mode inactive", - ) - role_name = str(action.parameters.get("role_name", "")).strip() - if not role_name: - return ActionResult( - success=False, - description=f"{agent.name} tried to create role with empty name", - ) - dept_id = action.parameters.get("department_id") - if dept_id is not None: - dept_id = str(dept_id) - tick = self.tick - role = company.org.add_role( - name=role_name, - holder_agent_id=agent.id, - department_id=dept_id, - created_tick=tick, - ) - # Reflect role claim in agent state. - if role_name not in agent.role_claims: - agent.role_claims.append(role_name) - event = f"[Tick {tick}] {agent.name} created role: {role_name}" - self._append_event(event, location=agent.location) - return ActionResult( - success=True, description=event, events=[event], - state_changes={ - "role_id": role.role_id, - "role_name": role_name, - f"company.org.roles.{role.role_id}.exists": { - "before": False, - "after": True, - }, - }, - ) - - def _resolve_form_team( - self, agent: AgentState, action: Action - ) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot form team — company mode inactive", - ) - team_name = str(action.parameters.get("team_name", "")).strip() - if not team_name: - return ActionResult( - success=False, - description=f"{agent.name} tried to form team with empty name", - ) - member_ids: list[str] = [ - str(m) for m in action.parameters.get("member_ids", []) - ] - min_members = company.org.min_members_for_team - if len(member_ids) < min_members: - return ActionResult( - success=False, - description=( - f"{agent.name} needs at least {min_members} members " - f"to form a team, got {len(member_ids)}" - ), - ) - # All members must be at same location and alive. - agent_loc = agent.location - for mid in member_ids: - member_state = self.agents.get(str(mid)) - if member_state is None or not member_state.alive: - return ActionResult( - success=False, - description=f"Member {mid} is not alive or does not exist", - ) - if member_state.location != agent_loc: - return ActionResult( - success=False, - description=( - f"Member {mid} is at {member_state.location}, " - f"not at {agent_loc}" - ), - ) - tick = self.tick - dept = company.org.add_department( - name=team_name, - lead_agent_id=agent.id, - member_agent_ids=member_ids, - created_tick=tick, - ) - if dept is None: - return ActionResult( - success=False, - description="Cannot form team: max departments reached", - ) - event = ( - f"[Tick {tick}] {agent.name} formed team {team_name} " - f"with {len(member_ids)} members" - ) - self._append_event(event, location=agent_loc) - return ActionResult( - success=True, description=event, events=[event], - state_changes={ - "dept_id": dept.dept_id, - "team_name": team_name, - f"company.org.departments.{dept.dept_id}.exists": { - "before": False, - "after": True, - }, - }, - ) - - def _resolve_interview( - self, agent: AgentState, action: Action - ) -> ActionResult: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return ActionResult( - success=False, - description=f"{agent.name} cannot interview — company mode inactive", - ) - candidate_id = str(action.parameters.get("candidate_id", "")) - candidate = self._find_available_company_candidate(candidate_id) - if candidate is None: - return ActionResult( - success=False, - description=f"Candidate {candidate_id} is not available", - ) - # Interview reveals personality and reduces joining cost. - cost_before = candidate.joining_cost - discount = max(1, int(cost_before * 0.25)) - candidate.joining_cost = max(0, cost_before - discount) - event = ( - f"[Tick {self.tick}] {agent.name} interviewed {candidate.name} — " - f"joining cost reduced from {cost_before} to {candidate.joining_cost}" - ) - self._append_event(event, location=agent.location) - return ActionResult( - success=True, description=event, events=[event], - state_changes={ - "candidate_id": candidate_id, - "joining_cost_before": cost_before, - "joining_cost_after": candidate.joining_cost, - }, - ) - - def _resolve_unknown(self, agent: AgentState, action: Action) -> ActionResult: - event = ( - f"[Tick {self.tick}] {agent.name} attempted '{action.verb}' " - f"— the world does not know how to resolve this physically" - ) - self._append_event(event, location=agent.location) - agent.last_action = f"attempted: {action.verb}" - return ActionResult(success=False, description=event, events=[event]) - - # --- Helpers --- - - def _find_agent_by_name(self, name: str, location: str) -> AgentState | None: - name_l = name.lower() - for peer_id in self._location_index.get(location, ()): - a = self.agents.get(peer_id) - if a and a.alive and a.name.lower() == name_l: - return a - return None - - def _is_adjacent(self, source: str, destination: str) -> bool: - if source == destination: - return True - return destination in self.location_graph.get(source, []) - - def _append_event( - self, - event: str, - *, - location: str | None = None, - locations: Iterable[str] | None = None, - ) -> None: - self.event_log.append(event) - visible_locations: set[str] = set() - if location is not None and location in self.locations: - visible_locations.add(location) - if locations is not None: - visible_locations.update(loc for loc in locations if loc in self.locations) - self._event_meta.append(EventVisibility(locations=visible_locations)) - self._trim_event_log() - - def _trim_event_log(self) -> None: - if len(self.event_log) > self.event_log_limit: - self.event_log = self.event_log[-self.event_log_limit:] - if len(self._event_meta) > len(self.event_log): - self._event_meta = self._event_meta[-len(self.event_log) :] - - def _apply_company_pressure(self) -> None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return - burn = max(0, int(company.operating_cost_per_tick)) - if burn: - before = company.cash - company.cash = max(0, company.cash - burn) - if company.cash != before: - self._append_event( - f"[Tick {self.tick}] Company burned {burn} cash for operations", - locations=self.locations, - ) - for demand in company.demand_streams: - if demand.status == "open" and self.tick > demand.deadline_tick: - demand.status = "missed" - self._append_event( - f"[Tick {self.tick}] Company missed demand {demand.request_id}", - locations=self.locations, - ) - - def _find_open_company_demand(self, request_id: str) -> CompanyDemand | None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - for demand in company.demand_streams: - if demand.request_id == request_id and demand.status == "open": - if self.tick > demand.deadline_tick: - demand.status = "missed" - return None - return demand - return None - - def _find_available_company_candidate(self, candidate_id: str) -> CompanyCandidate | None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - for candidate in company.candidate_pool: - if candidate.candidate_id == candidate_id and candidate.status == "available": - return candidate - return None - - def _find_company_artifact(self, artifact_id: str) -> CompanyArtifact | None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - for artifact in company.artifacts: - if artifact.artifact_id == artifact_id: - return artifact - return None - - def _company_summary(self) -> dict[str, Any] | None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled: - return None - open_count = sum(1 for demand in company.demand_streams if demand.status == "open") - delivered_count = sum( - 1 for demand in company.demand_streams if demand.status == "delivered" - ) - missed_count = sum(1 for demand in company.demand_streams if demand.status == "missed") - alive_agents = [agent for agent in self.agents.values() if agent.alive] - role_claims = { - agent.id: list(agent.role_claims) - for agent in alive_agents - if agent.role_claims - } - burn = max(0, int(company.operating_cost_per_tick)) - runway = None if burn == 0 else company.cash // burn - return { - "enabled": company.enabled, - "name": company.name, - "stage": company.stage, - "cash": company.cash, - "operating_cost_per_tick": burn, - "runway_ticks": runway, - "open_demand_count": open_count, - "delivered_count": delivered_count, - "missed_count": missed_count, - "team_size": len(alive_agents), - "active_contributor_count": sum( - 1 for agent in alive_agents if agent.last_action is not None - ) or len(alive_agents), - "role_claims": role_claims, - "artifact_count": len(company.artifacts), - "artifact_read_count": company.artifact_read_count, - "artifact_write_count": company.artifact_write_count, - "organization": self._company_organization_summary(alive_agents), - "survival_gauntlet": self._company_survival_gauntlet_summary( - company, alive_agents - ), - "demand_streams": [ - { - "request_id": demand.request_id, - "description": demand.description, - "required_item": demand.required_item, - "reward": demand.reward, - "deadline_tick": demand.deadline_tick, - "status": demand.status, - } - for demand in company.demand_streams - ], - "candidate_pool": [ - { - "candidate_id": candidate.candidate_id, - "name": candidate.name, - "personality": candidate.personality, - "location": candidate.location, - "joining_cost": candidate.joining_cost, - "role_claims": list(candidate.role_claims), - "status": candidate.status, - } - for candidate in company.candidate_pool - ], - "artifacts": [ - { - "artifact_id": artifact.artifact_id, - "kind": artifact.kind, - "title": artifact.title, - "body": artifact.body, - "created_by": artifact.created_by, - "updated_by": artifact.updated_by, - "location": artifact.location, - "tick_created": artifact.tick_created, - "tick_updated": artifact.tick_updated, - "revision": artifact.revision, - } - for artifact in company.artifacts - ], - "pending_suggestions": [ - dict(s) for s in company.pending_suggestions - ], - "org": company.org.to_summary(), - "market": { - "total_revenue_earned": ( - self.market.total_revenue_earned if self.market else 0 - ), - "active_shocks": ( - list(self.market.active_shocks) if self.market else [] - ), - "open_demand_count": ( - self.market.count_open_demands() if self.market else 0 - ), - }, - "valuation": { - "current_valuation": ( - self.valuation.current_valuation if self.valuation else 0 - ), - "ipo_triggered": ( - self.valuation.ipo_triggered if self.valuation else False - ), - "ipo_progress": ( - min(1.0, self.valuation.current_valuation / self.valuation.ipo_valuation_target) - if self.valuation and self.valuation.ipo_valuation_target > 0 - else 0 - ), - "consecutive_profitable_ticks": ( - self.valuation.consecutive_profitable_ticks if self.valuation else 0 - ), - }, - "space": ( - self.office_space.to_dict() if self.office_space - else {"stage": "garage", "capacity": 3} - ), - } - - def _apply_company_gauntlet_shocks(self) -> None: - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - gauntlet = company.survival_gauntlet - if not company.enabled or not bool(gauntlet.get("enabled", False)): - return - for shock in self._gauntlet_shocks(company): - shock_id = self._shock_id(shock) - if shock_id in company.gauntlet_applied_shocks: - continue - if int(shock.get("tick", 0)) != self.tick: - continue - self._apply_company_gauntlet_shock(company, shock, shock_id) - company.gauntlet_applied_shocks.append(shock_id) - - def _apply_company_gauntlet_shock( - self, - company: CompanyState, - shock: dict[str, Any], - shock_id: str, - ) -> None: - kind = str(shock.get("kind", "unknown")) - if kind == "founder_exit": - agent_id = str(shock.get("agent_id", "")) - agent = self.agents.get(agent_id) - if agent is None: - agent_name = str(shock.get("agent_name", "")) - agent = next( - ( - candidate - for candidate in self.agents.values() - if candidate.name == agent_name - ), - None, - ) - if agent is not None and agent.alive: - agent.alive = False - agent.death_cause = "company_shock: founder_exit" - self._location_index_remove(agent.id, agent.location) - self._append_event( - ( - f"[Tick {self.tick}] Company survival shock {shock_id}: " - f"founder exit removed {agent.name}" - ), - location=agent.location, - ) - else: - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: " - "founder exit had no active target" - ) - return - if kind == "market_shift": - for raw_demand in shock.get("demand_streams", []): - if not isinstance(raw_demand, dict): - continue - demand = CompanyDemand( - request_id=str(raw_demand.get("request_id", "")), - description=str(raw_demand.get("description", "")), - required_item=str(raw_demand.get("required_item", "")), - reward=max(0, int(raw_demand.get("reward", 0))), - deadline_tick=max(0, int(raw_demand.get("deadline_tick", self.tick))), - status=str(raw_demand.get("status", "open")), - ) - company.demand_streams = [ - existing - for existing in company.demand_streams - if existing.request_id != demand.request_id - ] - company.demand_streams.append(demand) - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: market demand shifted" - ) - return - if kind == "cash_crisis": - delta = int(shock.get("cash_delta", 0)) - company.cash = max(0, company.cash + delta) - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: cash changed by {delta}" - ) - return - if kind == "talent_turnover": - agent_ids = [str(agent_id) for agent_id in shock.get("agent_ids", [])] - for agent_id in agent_ids: - agent = self.agents.get(agent_id) - if agent is None or not agent.alive: - continue - agent.alive = False - agent.death_cause = "company_shock: talent_turnover" - self._location_index_remove(agent.id, agent.location) - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: " - f"talent turnover removed {len(agent_ids)} agents" - ) - return - if kind == "governance_stress": - stress_delta = max(0, int(shock.get("stress_delta", 1))) - for agent in self.agents.values(): - if agent.alive: - agent.stress = self._clamp_stat(agent.stress + stress_delta) - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: " - "governance stress increased coordination load" - ) - return - self._append_event( - f"[Tick {self.tick}] Company survival shock {shock_id}: unknown shock kind {kind}" - ) - - def _company_survival_gauntlet_summary( - self, - company: CompanyState, - alive_agents: list[AgentState], - ) -> dict[str, Any] | None: - gauntlet = company.survival_gauntlet - if not bool(gauntlet.get("enabled", False)): - return None - shocks = self._gauntlet_shocks(company) - applied = set(company.gauntlet_applied_shocks) - current = [shock for shock in shocks if self._shock_id(shock) in applied] - current_shock = current[-1] if current else None - prior_shocks = current[:-1] - upcoming = [shock for shock in shocks if self._shock_id(shock) not in applied] - org = self._company_organization_summary(alive_agents) - collapse = self._company_collapse_summary(company, alive_agents) - metrics = { - "cash_continuity": company.cash, - "delivery_continuity": sum( - 1 for demand in company.demand_streams if demand.status == "delivered" - ), - "decision_continuity": org["routine_stability_score"], - "knowledge_continuity": len(company.artifacts) + company.artifact_write_count, - "team_regeneration": len(alive_agents), - "strategy_adaptation": sum( - 1 for shock in applied if "market" in shock or "pivot" in shock - ), - } - return { - "enabled": True, - "current_shock": self._shock_summary(current_shock, "active") - if current_shock - else None, - "prior_shocks": [ - self._shock_summary(shock, "completed") for shock in prior_shocks - ], - "upcoming_shocks": [ - self._shock_summary(shock, "scheduled") for shock in upcoming - ], - "survival_metrics": metrics, - "recovery_windows": [ - { - "shock_id": self._shock_id(shock), - "ticks_since_shock": max(0, self.tick - int(shock.get("tick", 0))), - "recovered": not collapse["collapsed"] and bool(alive_agents), - } - for shock in current - ], - "organizational_survival_half_life": [ - { - "shock_id": self._shock_id(shock), - "ticks_survived_after_shock": max( - 0, self.tick - int(shock.get("tick", 0)) - ), - } - for shock in current - ], - "collapse": collapse, - } - - def _company_collapse_summary( - self, company: CompanyState, alive_agents: list[AgentState] - ) -> dict[str, Any]: - causes: list[str] = [] - if not alive_agents: - causes.append("no active agents") - if company.cash <= 0: - causes.append("cash exhausted") - return { - "collapsed": bool(causes), - "tick": self.tick if causes else None, - "cause_candidates": causes, - } - - @staticmethod - def _gauntlet_shocks(company: CompanyState) -> list[dict[str, Any]]: - raw_shocks = company.survival_gauntlet.get("shocks", []) - if not isinstance(raw_shocks, list): - return [] - shocks = [dict(shock) for shock in raw_shocks if isinstance(shock, dict)] - return sorted( - shocks, - key=lambda shock: (int(shock.get("tick", 0)), str(shock.get("shock_id", ""))), - ) - - @staticmethod - def _shock_id(shock: dict[str, Any]) -> str: - explicit = str(shock.get("shock_id", "")).strip() - if explicit: - return explicit - return f"{shock.get('kind', 'shock')}-{int(shock.get('tick', 0))}" - - def _shock_summary(self, shock: dict[str, Any] | None, status: str) -> dict[str, Any] | None: - if shock is None: - return None - return { - "shock_id": self._shock_id(shock), - "kind": str(shock.get("kind", "unknown")), - "tick": int(shock.get("tick", 0)), - "status": status, - } - - def _company_organization_summary( - self, alive_agents: list[AgentState] - ) -> dict[str, Any]: - clusters_by_label: dict[str, dict[str, Any]] = {} - for agent in sorted(alive_agents, key=lambda state: state.id): - label = self._organization_label_hint(agent) - evidence: list[str] = [] - if agent.role_claims: - evidence.append(f"role claims: {', '.join(agent.role_claims)}") - if agent.last_action: - evidence.append(f"last action: {agent.last_action}") - cluster = clusters_by_label.setdefault( - label, - { - "cluster_id": f"cluster-{label}", - "label_hint": label, - "agents": [], - "confidence": 0.35, - "evidence": [], - }, - ) - cluster["agents"].append(agent.name) - cluster["evidence"].extend(evidence[:2]) - cluster["confidence"] = min( - 0.95, float(cluster["confidence"]) + (0.15 if evidence else 0.05) - ) - - handoff_counts: dict[tuple[str, str], dict[str, Any]] = {} - for event in self.event_log[-100:]: - match = re.search(r"\] (?P.+?) gave \d+ .+ to (?P.+)$", event) - if not match: - continue - key = (match.group("source"), match.group("target")) - handoff = handoff_counts.setdefault( - key, - {"from": key[0], "to": key[1], "count": 0, "evidence": []}, - ) - handoff["count"] += 1 - if len(handoff["evidence"]) < 3: - handoff["evidence"].append(event) - - action_count = sum(1 for agent in alive_agents if agent.last_action) - artifact_updates = 0 - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if company.enabled: - artifact_updates = sum(artifact.revision for artifact in company.artifacts) - routine_stability_score = min( - 100, - action_count * 15 - + len(handoff_counts) * 20 - + min(30, artifact_updates * 5), - ) - - return { - "clusters": sorted( - clusters_by_label.values(), - key=lambda cluster: (cluster["label_hint"], cluster["agents"]), - ), - "handoff_loops": sorted( - handoff_counts.values(), - key=lambda handoff: (-int(handoff["count"]), handoff["from"], handoff["to"]), - ), - "routine_stability_score": routine_stability_score, - } - - @staticmethod - def _organization_label_hint(agent: AgentState) -> str: - if agent.role_claims: - return agent.role_claims[0].strip().lower().replace(" ", "-") or "unclaimed" - action = (agent.last_action or "").lower() - if "artifact" in action or "note" in action or "write" in action: - return "knowledge" - if "deliver" in action or "ship" in action: - return "delivery" - if "recruit" in action or "hire" in action: - return "talent" - return "unclaimed" - - # --- Advance tick sub-steps (company emergence) --- - - def _apply_market_update(self) -> None: - """Generate new demands and apply market shocks.""" - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled or self.market is None: - return - self.market.ticks_since_last_generation += 1 - if self.market.ticks_since_last_generation >= self.market.demand_generation_every: - self.market.generate_demands(company.stage, self.tick) - self.market.mark_expired_shocks(self.tick) - # Random shock check (low probability per tick). - if self.market.shock_pool and self.market.count_open_demands() > 0: - shock = self.market.apply_market_shock(company.stage, self.tick) - if shock: - desc = shock.get("description", shock.get("kind", "")) - self._append_event( - f"[Tick {self.tick}] Market shock: {desc}", - locations=self.locations, - ) - - def _apply_pattern_detection(self) -> None: - """Run pattern detection and generate suggestions for all agents.""" - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled or self.pattern_detector is None: - return - self.pattern_detector.prune_logs(self.tick) - for agent_id, agent_state in self.agents.items(): - if not agent_state.alive: - continue - suggestions = self.pattern_detector.generate_suggestions( - agent_id, self.tick, company.stage, - ) - for suggestion in suggestions: - # Deduplicate: don't re-suggest same category if already pending. - existing_cats = { - s.get("category") for s in company.pending_suggestions - if s.get("target_agent_id") == agent_id - } - if suggestion["category"] not in existing_cats: - company.pending_suggestions.append(suggestion) - self._append_event( - f"[Tick {self.tick}] Suggestion for {agent_state.name}: " - f"{suggestion['message']}", - location=agent_state.location, - ) - # Expire old suggestions. - expiry = self.pattern_detector.suggestion_expiry_ticks - company.pending_suggestions = [ - s for s in company.pending_suggestions - if s.get("tick", 0) + expiry > self.tick - ] - - def _apply_space_pressure(self) -> None: - """Check office capacity and apply crowding pressure.""" - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled or self.office_space is None: - return - team_size = sum(1 for a in self.agents.values() if a.alive) - result = self.office_space.apply_capacity_pressure(team_size) - if result["over_capacity"]: - self._append_event( - f"[Tick {self.tick}] Office over capacity " - f"({team_size}/{self.office_space.capacity})", - locations=self.locations, - ) - expanded, cost = self.office_space.auto_expand(team_size, company.cash) - if expanded: - company.cash = max(0, company.cash - cost) - self._append_event( - f"[Tick {self.tick}] Office expanded to {self.office_space.stage} " - f"(cost {cost}, new capacity {self.office_space.capacity})", - locations=self.locations, - ) - - def _apply_valuation_update(self) -> None: - """Calculate valuation and check IPO conditions.""" - company = self.company if isinstance(self.company, CompanyState) else CompanyState() - if not company.enabled or self.valuation is None: - return - team_size = sum(1 for a in self.agents.values() if a.alive) - dept_count = company.org.department_count() - cumulative = self.market.total_revenue_earned if self.market else 0 - cash = company.cash - entry = self.valuation.update( - cumulative_revenue=cumulative, - team_size=team_size, - department_count=dept_count, - cash_reserve=cash, - tick=self.tick, - ) - if entry.get("ipo_triggered"): - self._append_event( - f"[Tick {self.tick}] IPO TRIGGERED — valuation reached " - f"{entry['valuation']}", - locations=self.locations, - ) - - def _aligned_event_meta(self) -> list[EventVisibility]: - missing = max(0, len(self.event_log) - len(self._event_meta)) - unknown = [EventVisibility() for _ in range(missing)] - return unknown + self._event_meta[-len(self.event_log) :] - - def consume_pending_births(self) -> list[dict[str, str]]: - """Return and clear births produced by lifecycle progression.""" - births = list(self.pending_births) - self.pending_births.clear() - return births - - def consume_pending_recruits(self) -> list[dict[str, Any]]: - """Return and clear company recruits produced by recruit intents.""" - recruits = list(self.pending_recruits) - self.pending_recruits.clear() - return recruits - - def _life_stage_for_age(self, age_ticks: int) -> str: - juvenile_at = int(self.lifecycle.life_stage_thresholds.get("juvenile", 100)) - adult_at = int(self.lifecycle.life_stage_thresholds.get("adult", 400)) - elder_at = int(self.lifecycle.life_stage_thresholds.get("elder", 1000)) - if age_ticks < juvenile_at: - return "infant" - if age_ticks < adult_at: - return "juvenile" - if age_ticks < elder_at: - return "adult" - return "elder" - - @staticmethod - def _clamp_stat(value: int) -> int: - return max(0, min(100, value)) - - def _apply_lifecycle_updates(self) -> None: - infectious_by_location: dict[str, int] = {} - if self.pressure.disease_enabled: - for state in self.agents.values(): - if state.alive and bool(state.contagion_profile.get("infectious", False)): - infectious_by_location[state.location] = ( - infectious_by_location.get(state.location, 0) + 1 - ) - - for state in self.agents.values(): - if not state.alive: - continue - - state.age_ticks += self.lifecycle.age_tick_step - state.life_stage = self._life_stage_for_age(state.age_ticks) - - if self.pressure.survival_enabled: - self._apply_survival_needs(state) - - if self.pressure.disease_enabled: - self._apply_disease_progression(state, infectious_by_location) - - if state.pregnancy is None: - self._try_start_pregnancy(state) - if state.pregnancy is not None: - progress_ticks = int(state.pregnancy.get("progress_ticks", 0)) + 1 - state.pregnancy["progress_ticks"] = progress_ticks - target_ticks = int( - state.pregnancy.get( - "target_ticks", - self.lifecycle.pregnancy_duration_min_ticks, - ) - ) - if progress_ticks >= max(1, target_ticks): - self._queue_birth(state) - state.pregnancy = None - - if self.axioms.social_tracking: - self._compact_social_maps(state) - state.vitality = self._clamp_stat(state.vitality) - state.stress = self._clamp_stat(state.stress) - state.hunger = self._clamp_stat(state.hunger) - state.fatigue = self._clamp_stat(state.fatigue) - if self.axioms.mortality: - self._check_and_apply_death(state) - - def _apply_environment_updates(self) -> None: - if not self.pressure.environment_enabled: - return - seasons = ("spring", "summer", "autumn", "winter") - season_index = ( - self.tick // max(1, self.pressure.season_length_ticks) - ) % len(seasons) - new_season = seasons[season_index] - if self.environment.get("season") != new_season: - self.environment["season"] = new_season - self._append_event(f"Season changed to {new_season}.", locations=self.locations) - - def _apply_resource_decay(self) -> None: - every = int(self.pressure.resource_decay_every) - if not self.pressure.resources_enabled or every <= 0 or self.tick % every != 0: - return - for location, items in self.location_items.items(): - for item in list(items): - if items[item] <= 0: - del items[item] - continue - decay_quantity = self._resource_decay_quantity(item, every) - if decay_quantity <= 0: - continue - items[item] -= min(items[item], decay_quantity) - if items[item] <= 0: - del items[item] - self._append_event(f"Resource {item} decayed at {location}.", location=location) - - def _resource_decay_quantity(self, item: str, every: int) -> int: - if not item.startswith("stored:"): - return 1 - - multiplier = max(0.0, float(self.pressure.storage_decay_multiplier)) - if multiplier <= 0.0: - return 0 - - # Stored resources decay deterministically using accumulated due ticks: - # 0.0 never decays, 1.0 decays every due tick, 0.5 decays every other due tick. - due_tick_count = self.tick // every - previous_due_tick_count = max(0, due_tick_count - 1) - return int(due_tick_count * multiplier) - int( - previous_due_tick_count * multiplier - ) - - def _apply_resource_regeneration(self) -> None: - if not self.pressure.resources_enabled: - return - global_every = int(self.pressure.resource_regeneration_every) - if global_every > 0 and self.tick % global_every != 0: - return - for location, resources in self.resource_zones.items(): - bucket = self.location_items.setdefault(location, {}) - for item, spec in resources.items(): - every = int(spec.get("every", 1)) - if every <= 0 or self.tick % every != 0: - continue - quantity = int(spec.get("quantity", 1)) - bucket[item] = bucket.get(item, 0) + quantity - self._append_event( - f"Resource {item} regenerated at {location}.", - location=location, - ) - - def _apply_survival_needs(self, state: AgentState) -> None: - if state.last_action and "rest" in state.last_action: - state.vitality += self.lifecycle.vitality_rest_gain - state.stress -= self.lifecycle.stress_rest_reduction - state.hunger = max(0, state.hunger - self.lifecycle.hunger_rest_reduction) - state.fatigue = max(0, state.fatigue - self.lifecycle.fatigue_rest_reduction) - else: - state.vitality -= self.lifecycle.vitality_loss_per_tick - state.stress += self.lifecycle.stress_gain_per_tick - state.hunger += self.lifecycle.hunger_gain_per_tick - state.fatigue += self.lifecycle.fatigue_gain_per_tick - - if self.axioms.auto_eat and state.hunger >= self.lifecycle.auto_eat_hunger_threshold: - self._attempt_auto_eat(state) - - if state.hunger >= self.lifecycle.hunger_vitality_penalty_threshold: - hunger_pressure = ( - state.hunger - self.lifecycle.hunger_vitality_penalty_threshold - ) / max(1, 100 - self.lifecycle.hunger_vitality_penalty_threshold) - if random.random() < max(0.0, min(1.0, hunger_pressure)): - state.vitality -= max( - 1, - int(round(self.lifecycle.needs_penalty * (1.0 + hunger_pressure))), - ) - if state.fatigue >= self.lifecycle.fatigue_stress_penalty_threshold: - fatigue_pressure = ( - state.fatigue - self.lifecycle.fatigue_stress_penalty_threshold - ) / max(1, 100 - self.lifecycle.fatigue_stress_penalty_threshold) - if random.random() < max(0.0, min(1.0, fatigue_pressure)): - state.stress += max( - 1, - int(round(self.lifecycle.needs_penalty * (1.0 + fatigue_pressure))), - ) - - def _apply_disease_progression( - self, - state: AgentState, - infectious_by_location: dict[str, int], - ) -> None: - profile = state.contagion_profile - if profile.get("infectious", False): - profile["infected_ticks"] = int(profile.get("infected_ticks", 0)) + 1 - state.vitality -= self.lifecycle.disease_vitality_penalty - state.stress += self.lifecycle.disease_stress_penalty - recovered = int(profile["infected_ticks"]) >= self.lifecycle.disease_recovery_ticks - if not recovered and random.random() < self._recovery_chance(state): - recovered = True - if recovered: - profile["status"] = "recovered" - profile["infectious"] = False - profile["infected_ticks"] = 0 - profile["exposure_count"] = 0 - self._append_event( - f"[Tick {self.tick}] {state.name} recovered from illness", - location=state.location, - ) - elif profile.get("status", "susceptible") == "susceptible": - if infectious_by_location.get(state.location, 0) > 0: - profile["exposure_count"] = int(profile.get("exposure_count", 0)) + 1 - if int(profile["exposure_count"]) < max( - 1, - self.lifecycle.disease_exposure_threshold, - ): - return - if random.random() < self._infection_chance( - state=state, - infectious_neighbors=infectious_by_location.get(state.location, 0), - ): - profile["status"] = "infected" - profile["infectious"] = True - profile["infected_ticks"] = 0 - self._append_event( - f"[Tick {self.tick}] {state.name} became ill after local exposure", - location=state.location, - ) - else: - profile["exposure_count"] = max( - 0, - int(profile.get("exposure_count", 0)) - - self.lifecycle.disease_exposure_decay_per_tick, - ) - - def _attempt_auto_eat(self, state: AgentState) -> None: - for food in self.lifecycle.food_items: - if state.inventory.get(food, 0) > 0: - state.inventory[food] -= 1 - state.hunger = max(0, state.hunger - self.lifecycle.nourishment_gain_per_food) - self._append_event( - f"[Tick {self.tick}] {state.name} consumed {food} to reduce hunger", - location=state.location, - ) - return - - def _try_start_pregnancy(self, state: AgentState) -> None: - if self._conception_blocker(state) is not None: - return - - partner = self._find_conception_partner(state) - if partner is None: - return - - chance = self._conception_chance(state, partner) - if random.random() >= chance: - return - - self._start_pregnancy(state, partner) - - def _conception_blocker(self, state: AgentState) -> str | None: - if not state.alive: - return f"{state.name} is not alive" - if state.life_stage != "adult": - return f"{state.name} is not an adult" - if state.vitality < self.lifecycle.conception_min_vitality: - return f"{state.name} lacks sufficient vitality" - if state.stress > self.lifecycle.conception_max_stress: - return f"{state.name} is too stressed" - if state.hunger > self.lifecycle.auto_eat_hunger_threshold: - return f"{state.name} is too hungry" - return None - - def _start_pregnancy(self, state: AgentState, partner: AgentState) -> str: - min_ticks = max(1, self.lifecycle.pregnancy_duration_min_ticks) - max_ticks = max(min_ticks, self.lifecycle.pregnancy_duration_max_ticks) - duration_ticks = random.randint(min_ticks, max_ticks) - state.pregnancy = { - "partner_hint": partner.name, - "progress_ticks": 0, - "target_ticks": duration_ticks, - } - event = f"[Tick {self.tick}] {state.name} started pregnancy (partner: {partner.name})" - self._append_event(event, location=state.location) - return event - - def _find_conception_partner(self, state: AgentState) -> AgentState | None: - candidates: list[AgentState] = [] - for other_id in self._location_index.get(state.location, ()): - if other_id == state.id: - continue - other = self.agents.get(other_id) - if not other or not other.alive: - continue - if self._conception_blocker(other) is not None: - continue - candidates.append(other) - if not candidates: - return None - return random.choice(candidates) - - def _conception_chance(self, state: AgentState, partner: AgentState) -> float: - vitality_signal = ( - state.vitality + partner.vitality - ) / 200.0 - stress_signal = ( - state.stress + partner.stress - ) / 200.0 - hunger_signal = ( - state.hunger + partner.hunger - ) / 200.0 - - chance = self.lifecycle.conception_base_chance - chance += vitality_signal * self.lifecycle.conception_vitality_weight - chance -= stress_signal * self.lifecycle.conception_stress_weight - chance -= hunger_signal * self.lifecycle.conception_hunger_weight - - if state.contagion_profile.get("infectious", False): - chance -= self.lifecycle.conception_infection_penalty - if partner.contagion_profile.get("infectious", False): - chance -= self.lifecycle.conception_infection_penalty - - if self.axioms.social_tracking: - trust_signal = ( - state.trust_by_agent.get(partner.id, 0.0) - + partner.trust_by_agent.get(state.id, 0.0) - ) / 2.0 - obligation_signal = ( - state.obligation_by_agent.get(partner.id, 0.0) - + partner.obligation_by_agent.get(state.id, 0.0) - ) / 2.0 - chance += trust_signal * self.lifecycle.conception_trust_weight - chance -= obligation_signal * self.lifecycle.conception_obligation_weight - - return max(0.0, min(0.85, chance)) - - def _infection_chance(self, state: AgentState, infectious_neighbors: int) -> float: - profile = state.contagion_profile - exposure = int(profile.get("exposure_count", 0)) - resilience = state.immune_resilience / 100.0 - needs_vulnerability = (state.hunger + state.fatigue + state.stress) / 300.0 - - chance = self.lifecycle.disease_transmission_base_chance - chance += infectious_neighbors * self.lifecycle.disease_contact_weight - chance += exposure * self.lifecycle.disease_exposure_weight - chance += needs_vulnerability * self.lifecycle.disease_need_vulnerability_weight - chance -= resilience * self.lifecycle.disease_resilience_protection_weight - return max(0.0, min(0.95, chance)) - - def _recovery_chance(self, state: AgentState) -> float: - resilience = state.immune_resilience / 100.0 - chance = self.lifecycle.disease_recovery_base_chance - chance += resilience * self.lifecycle.disease_recovery_resilience_weight - if state.last_action and "rest" in state.last_action: - chance += self.lifecycle.disease_recovery_rest_bonus - return max(0.0, min(0.95, chance)) - - def _compact_social_maps(self, state: AgentState) -> None: - limit = max(1, self.lifecycle.social_memory_max_entries) - state.trust_by_agent = { - agent_id: value * 0.995 for agent_id, value in state.trust_by_agent.items() - } - state.obligation_by_agent = { - agent_id: value * 0.997 - for agent_id, value in state.obligation_by_agent.items() - } - - if len(state.trust_by_agent) > limit: - keep = sorted(state.trust_by_agent.keys())[-limit:] - state.trust_by_agent = {k: state.trust_by_agent[k] for k in keep} - - if len(state.obligation_by_agent) > limit: - keep = sorted(state.obligation_by_agent.keys())[-limit:] - state.obligation_by_agent = {k: state.obligation_by_agent[k] for k in keep} - - def _check_and_apply_death(self, state: AgentState) -> None: - if state.vitality <= 0: - self._mark_dead(state, "vitality_depletion") - return - if state.life_stage == "elder" and state.vitality < 8 and state.stress > 90: - self._mark_dead(state, "age_related_failure") - - def _mark_dead(self, state: AgentState, cause: str) -> None: - if not state.alive: - return - self._location_index_remove(state.id, state.location) - state.alive = False - state.death_cause = cause - state.last_action = "deceased" - self.total_deaths += 1 - self._append_event( - f"[Tick {self.tick}] {state.name} died ({cause})", - location=state.location, - ) - - def _queue_birth(self, parent: AgentState) -> None: - newborn_id = str(uuid.uuid4())[:8] - newborn_name = f"Newborn-{newborn_id}" - self.pending_births.append( - { - "id": newborn_id, - "name": newborn_name, - "location": parent.location, - "parent_name": parent.name, - } - ) - self.total_births += 1 - self._append_event( - f"[Tick {self.tick}] {parent.name} gave birth to {newborn_name}", - location=parent.location, - ) - - @classmethod - def _deserialize_event_log_payload( - cls, - payload: dict[str, Any], - known_locations: set[str], - ) -> tuple[list[str], list[EventVisibility]]: - event_log = payload.get("event_log") - if isinstance(event_log, list): - events: list[str] = [] - meta: list[EventVisibility] = [] - for entry in event_log: - if isinstance(entry, str): - events.append(entry) - meta.append(EventVisibility()) - continue - if not isinstance(entry, dict): - continue - raw_event = entry.get("event", entry.get("description")) - if not isinstance(raw_event, str): - continue - events.append(raw_event) - meta.append( - cls._event_visibility_from_payload( - entry.get("visibility"), - known_locations, - ) - ) - return events, meta - - recent_events = payload.get("recent_events", []) - if not isinstance(recent_events, list): - return [], [] - events = [event for event in recent_events if isinstance(event, str)] - # Legacy snapshots have only strings. Preserve them for global observers, - # but attach no local visibility so load does not leak old events locally. - return events, [EventVisibility() for _ in events] - - @staticmethod - def _event_visibility_from_payload( - raw_visibility: Any, - known_locations: set[str], - ) -> EventVisibility: - if not isinstance(raw_visibility, dict): - return EventVisibility() - raw_locations = raw_visibility.get("locations", []) - if not isinstance(raw_locations, list): - return EventVisibility() - return EventVisibility( - locations={ - str(location) - for location in raw_locations - if str(location) in known_locations - } - ) - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> World: - locations = list(payload.get("locations", [])) - event_log, event_meta = cls._deserialize_event_log_payload( - payload, - known_locations={str(location) for location in locations}, - ) - world = cls( - name=str(payload.get("name", "AnteLab World")), - tick=int(payload.get("tick", 0)), - locations=locations, - location_graph={ - key: list(value) - for key, value in dict(payload.get("location_graph", {})).items() - }, - location_items={ - loc: {item: int(qty) for item, qty in items.items()} - for loc, items in dict(payload.get("location_items", {})).items() - }, - location_features={ - loc: {feature: int(qty) for feature, qty in features.items()} - for loc, features in dict(payload.get("location_features", {})).items() - }, - resource_zones={ - loc: { - item: { - key: int(value) - for key, value in dict(spec).items() - } - for item, spec in dict(resources).items() - } - for loc, resources in dict(payload.get("resource_zones", {})).items() - }, - recipes={ - name: Recipe( - inputs={k: int(v) for k, v in recipe.get("inputs", {}).items()}, - outputs={k: int(v) for k, v in recipe.get("outputs", {}).items()}, - ) - for name, recipe in dict(payload.get("recipes", {})).items() - }, - company=payload.get("company"), - event_log_limit=int(payload.get("event_log_limit", 2000)), - event_log=event_log, - pressure=dict(payload.get("pressure", {})), - ) - world._event_meta = event_meta[-len(world.event_log) :] - world._trim_event_log() - experiment = dict(payload.get("experiment", {})) - world.axioms = ExperimentAxioms( - perception=str(experiment.get("perception", "local")), - communication=str(experiment.get("communication", "colocated")), - social_tracking=bool(experiment.get("social_tracking", True)), - auto_eat=bool(experiment.get("auto_eat", False)), - mortality=bool(experiment.get("mortality", True)), - memory_size=int(experiment.get("memory_size", 50)), - ) - metrics = dict(payload.get("metrics", {})) - world.total_births = int(metrics.get("total_births", 0)) - world.total_deaths = int(metrics.get("total_deaths", 0)) - environment = payload.get("environment") - if isinstance(environment, dict): - world.environment.update(environment) - - agents_raw = payload.get("agents", []) - for agent in agents_raw: - if not isinstance(agent, dict): - continue - location = str( - agent.get( - "location", - world.locations[0] if world.locations else "unknown", - ) - ) - world.register_agent( - agent_id=str(agent.get("id", "")), - name=str(agent.get("name", "")), - location=location, - inventory={k: int(v) for k, v in dict(agent.get("inventory", {})).items()}, - age_ticks=int(agent.get("age_ticks", 0)), - vitality=int(agent.get("vitality", 100)), - immune_resilience=int(agent.get("immune_resilience", 50)), - stress=int(agent.get("stress", 0)), - hunger=int(agent.get("hunger", 0)), - fatigue=int(agent.get("fatigue", 0)), - pregnancy=dict(agent.get("pregnancy", {})) if agent.get("pregnancy") else None, - contagion_profile=dict(agent.get("contagion_profile", {})), - role_claims=[str(claim) for claim in agent.get("role_claims", [])], - alive=bool(agent.get("alive", True)), - ) - state = world.agents[str(agent.get("id", ""))] - state.last_action = agent.get("last_action") - state.death_cause = agent.get("death_cause") - state.life_stage = str(agent.get("life_stage", state.life_stage)) - return world - - -def _suggestion_dept_name(category: str) -> str: - """Map a suggestion category to a default department name.""" - _names: dict[str, str] = { - "delivery": "Delivery Department", - "crafting": "Production Department", - "coordination": "Joint Decision Council", - "delegation": "Management Office", - "planning": "Company Charter Committee", - } - return _names.get(category, f"{category.title()} Department") - - -_PRIMITIVES: dict[str, Any] = { - "move": World._resolve_move, - "go": World._resolve_move, - "walk": World._resolve_move, - "say": World._resolve_say, - "speak": World._resolve_say, - "talk": World._resolve_say, - "tell": World._resolve_say, - "give": World._resolve_give, - "offer": World._resolve_give, - "hand": World._resolve_give, - "take": World._resolve_take, - "grab": World._resolve_take, - "pick_up": World._resolve_take, - "pickup": World._resolve_take, - "harvest": World._resolve_harvest, - "gather": World._resolve_harvest, - "forage": World._resolve_harvest, - "store": World._resolve_store, - "stash": World._resolve_store, - "build_shelter": World._resolve_build_shelter, - "shelter": World._resolve_build_shelter, - "consume": World._resolve_consume, - "eat": World._resolve_consume, - "treat": World._resolve_treat, - "reproduce": World._resolve_reproduce, - "examine": World._resolve_examine, - "look": World._resolve_examine, - "inspect": World._resolve_examine, - "observe": World._resolve_examine, - "rest": World._resolve_rest, - "wait": World._resolve_rest, - "sleep": World._resolve_rest, - "craft": World._resolve_craft, - "make": World._resolve_craft, - "build": World._resolve_craft, - "deliver": World._resolve_deliver, - "ship": World._resolve_deliver, - "recruit": World._resolve_recruit, - "hire": World._resolve_recruit, - "write_artifact": World._resolve_write_artifact, - "write_note": World._resolve_write_artifact, - "read_artifact": World._resolve_read_artifact, - "read_note": World._resolve_read_artifact, - "update_artifact": World._resolve_update_artifact, - "revise_artifact": World._resolve_update_artifact, - "accept_suggestion": World._resolve_accept_suggestion, - "modify_suggestion": World._resolve_modify_suggestion, - "create_role": World._resolve_create_role, - "form_team": World._resolve_form_team, - "interview": World._resolve_interview, -} diff --git a/antelab/experiments/__init__.py b/antelab/experiments/__init__.py deleted file mode 100644 index 2dad318..0000000 --- a/antelab/experiments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Experiment orchestration and statistical analysis utilities.""" diff --git a/antelab/experiments/compare.py b/antelab/experiments/compare.py deleted file mode 100644 index bccee06..0000000 --- a/antelab/experiments/compare.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Run artifact comparison helpers.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -LIFECYCLE_METRICS = { - "alive_count", - "final_alive_agents", - "total_births", - "final_births", - "total_deaths", - "final_deaths", - "peak_disease_infected", - "average_hunger", - "average_fatigue", - "birth_rate", - "death_rate", -} - -BEHAVIOR_METRICS = { - "total_communications", - "total_resource_transfers", - "final_resource_total", - "total_cooperation_events", - "total_failed_actions", - "total_actions", - "failure_rate", - "communications_per_tick", - "resource_transfers_per_tick", - "cooperation_events_per_tick", - "failed_actions_per_tick", - "actions_per_tick", - "cooperation_per_transfer", - "take_to_give_ratio", - "action_move_count", - "action_say_count", - "action_give_count", - "action_take_count", - "action_rest_count", -} - - -def compare_artifacts(a: Path, b: Path) -> dict[str, Any]: - left = json.loads(a.read_text(encoding="utf-8")) - right = json.loads(b.read_text(encoding="utf-8")) - left_metrics = dict(left.get("final_metrics", {})) - right_metrics = dict(right.get("final_metrics", {})) - left_keys = set(left_metrics.keys()) - right_keys = set(right_metrics.keys()) - keys = sorted(left_keys & right_keys) - deltas = {} - for key in keys: - left_raw = left_metrics[key] - right_raw = right_metrics[key] - if not isinstance(left_raw, int | float) or not isinstance(right_raw, int | float): - # Skip structured/non-numeric metrics (for example action distributions). - continue - left_value = float(left_raw) - right_value = float(right_raw) - deltas[key] = right_value - left_value - - lifecycle_deltas = {key: value for key, value in deltas.items() if key in LIFECYCLE_METRICS} - behavior_deltas = {key: value for key, value in deltas.items() if key in BEHAVIOR_METRICS} - other_deltas = { - key: value - for key, value in deltas.items() - if key not in LIFECYCLE_METRICS and key not in BEHAVIOR_METRICS - } - - return { - "left": str(a), - "right": str(b), - "metric_deltas": deltas, - "missing_metrics": { - "left_only": sorted(left_keys - right_keys), - "right_only": sorted(right_keys - left_keys), - }, - "metric_groups": { - "lifecycle": lifecycle_deltas, - "behavior": behavior_deltas, - "other": other_deltas, - }, - } diff --git a/antelab/experiments/orchestrator.py b/antelab/experiments/orchestrator.py deleted file mode 100644 index a9ad1f4..0000000 --- a/antelab/experiments/orchestrator.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Batch experiment orchestrator.""" - -from __future__ import annotations - -import asyncio -import json -import random -from concurrent.futures import ProcessPoolExecutor, as_completed -from dataclasses import asdict, dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from antelab.config.identity import config_hash -from antelab.config.loader import load_config -from antelab.engine.agent import Agent -from antelab.engine.receipts import ( - action_evidence_from_trace, - build_receipt_report, - render_receipt_markdown, -) -from antelab.engine.tick import TickRunner -from antelab.engine.world import ExperimentAxioms, LifecycleParams, PressureRuntime, World -from antelab.llm.client import LLMClient - - -@dataclass -class ExperimentRunRequest: - config_path: str - seed: int - ticks: int - run_mode: str = "matrix" - - -DEFAULT_MANIFEST_FILENAME = "run-matrix-manifest.json" -MANIFEST_VERSION = 1 -REPO_ROOT = Path(__file__).resolve().parents[2] - - -class MatrixRunError(RuntimeError): - """Raised after manifest-enabled matrix runs record one or more failures.""" - - def __init__(self, failures: list[dict[str, str]]) -> None: - self.failures = failures - noun = "job" if len(failures) == 1 else "jobs" - details = "; ".join(f"{failure['key']}: {failure['error']}" for failure in failures) - super().__init__(f"{len(failures)} matrix {noun} failed: {details}") - - -def _matrix_process_worker( - args: tuple[int, str, int, int, str], -) -> tuple[int, str]: - """Picklable worker: one isolated process per matrix cell (own global ``random``).""" - idx, config_path, seed, ticks, output_dir_str = args - request = ExperimentRunRequest( - config_path=config_path, - seed=seed, - ticks=ticks, - ) - path = asyncio.run(_run_request(request=request, output_dir=Path(output_dir_str))) - return idx, str(path) - - -def matrix_job_key(request: ExperimentRunRequest) -> str: - """Return a stable, human-readable key for a matrix job.""" - config_path = _canonical_config_identity(request.config_path) - return f"{request.run_mode}:{config_path}:seed={int(request.seed)}:ticks={int(request.ticks)}" - - -def _canonical_config_identity(config_path: str) -> str: - path = Path(config_path).expanduser() - if not path.is_absolute(): - path = Path.cwd() / path - resolved = path.resolve(strict=False) - try: - return resolved.relative_to(REPO_ROOT).as_posix() - except ValueError: - return resolved.as_posix() - - -def _utc_now() -> str: - return datetime.now(UTC).replace(microsecond=0).isoformat() - - -def _default_manifest_path(output_dir: Path) -> Path: - return output_dir / DEFAULT_MANIFEST_FILENAME - - -def _new_manifest() -> dict[str, Any]: - return {"version": MANIFEST_VERSION, "jobs": {}} - - -def _load_manifest(path: Path) -> dict[str, Any]: - if not path.exists(): - return _new_manifest() - manifest = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(manifest, dict): - return _new_manifest() - if manifest.get("version") != MANIFEST_VERSION: - manifest["version"] = MANIFEST_VERSION - if not isinstance(manifest.get("jobs"), dict): - manifest["jobs"] = {} - return manifest - - -def _write_manifest(path: Path, manifest: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_name(f"{path.name}.tmp") - tmp_path.write_text( - json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - tmp_path.replace(path) - - -def _artifact_path_for_manifest(artifact_path: Path, output_dir: Path) -> str: - try: - return artifact_path.resolve().relative_to(output_dir.resolve()).as_posix() - except ValueError: - return str(artifact_path) - - -def _artifact_path_from_manifest(raw_path: object, output_dir: Path) -> Path | None: - if not isinstance(raw_path, str) or not raw_path: - return None - path = Path(raw_path) - if path.is_absolute(): - return path - return output_dir / path - - -def _manifest_record(request: ExperimentRunRequest, status: str) -> dict[str, Any]: - config_path = Path(request.config_path) - return { - "artifact_path": None, - "canonical_config_path": _canonical_config_identity(request.config_path), - "completed_at": None, - "config_name": config_path.name, - "config_path": request.config_path, - "error": None, - "key": matrix_job_key(request), - "run_mode": request.run_mode, - "seed": int(request.seed), - "started_at": None, - "status": status, - "ticks": int(request.ticks), - } - - -def _existing_completed_artifact( - manifest: dict[str, Any], - request: ExperimentRunRequest, - output_dir: Path, -) -> Path | None: - record = manifest["jobs"].get(matrix_job_key(request)) - if not isinstance(record, dict) or record.get("status") != "complete": - return None - artifact_path = _artifact_path_from_manifest(record.get("artifact_path"), output_dir) - if artifact_path is None or not artifact_path.exists(): - return None - return artifact_path - - -def _mark_manifest_started( - manifest: dict[str, Any], - request: ExperimentRunRequest, -) -> None: - record = _manifest_record(request, "running") - record["started_at"] = _utc_now() - manifest["jobs"][matrix_job_key(request)] = record - - -def _mark_manifest_skipped( - manifest: dict[str, Any], - request: ExperimentRunRequest, - artifact_path: Path, - output_dir: Path, -) -> None: - record = manifest["jobs"].get(matrix_job_key(request), _manifest_record(request, "complete")) - record.update( - { - "artifact_path": _artifact_path_for_manifest(artifact_path, output_dir), - "error": None, - "status": "complete", - } - ) - manifest["jobs"][matrix_job_key(request)] = record - - -def _mark_manifest_complete( - manifest: dict[str, Any], - request: ExperimentRunRequest, - artifact_path: Path, - output_dir: Path, -) -> None: - record = manifest["jobs"].get(matrix_job_key(request), _manifest_record(request, "complete")) - record.update( - { - "artifact_path": _artifact_path_for_manifest(artifact_path, output_dir), - "completed_at": _utc_now(), - "error": None, - "status": "complete", - } - ) - if record.get("started_at") is None: - record["started_at"] = record["completed_at"] - manifest["jobs"][matrix_job_key(request)] = record - - -def _mark_manifest_failed( - manifest: dict[str, Any], - request: ExperimentRunRequest, - error: BaseException, -) -> dict[str, str]: - record = manifest["jobs"].get(matrix_job_key(request), _manifest_record(request, "failed")) - error_text = f"{type(error).__name__}: {error}" - record.update( - { - "artifact_path": None, - "completed_at": _utc_now(), - "error": error_text, - "status": "failed", - } - ) - if record.get("started_at") is None: - record["started_at"] = record["completed_at"] - manifest["jobs"][matrix_job_key(request)] = record - return {"key": matrix_job_key(request), "error": error_text} - - -def _build_final_metrics( - world_snapshot: dict[str, Any], - observer_summary: dict[str, Any], - ticks: int, - receipt_summary: dict[str, Any] | None = None, -) -> dict[str, float]: - metrics: dict[str, float] = { - key: float(value) - for key, value in dict(world_snapshot.get("metrics", {})).items() - if isinstance(value, int | float) - } - - measurement_metrics = { - "total_communications": float(observer_summary.get("total_communications", 0.0)), - "total_resource_transfers": float(observer_summary.get("total_resource_transfers", 0.0)), - "total_cooperation_events": float(observer_summary.get("total_cooperation_events", 0.0)), - "total_failed_actions": float(observer_summary.get("total_failed_actions", 0.0)), - "total_actions": float(observer_summary.get("total_actions", 0.0)), - "failure_rate": float(observer_summary.get("failure_rate", 0.0)), - "final_alive_agents": float( - observer_summary.get("final_alive_agents", metrics.get("alive_count", 0.0)) - ), - "final_resource_total": float(observer_summary.get("final_resource_total", 0.0)), - "peak_disease_infected": float(observer_summary.get("peak_disease_infected", 0.0)), - "final_births": float( - observer_summary.get("final_births", metrics.get("total_births", 0.0)) - ), - "final_deaths": float( - observer_summary.get("final_deaths", metrics.get("total_deaths", 0.0)) - ), - } - metrics.update(measurement_metrics) - - action_distribution = dict(observer_summary.get("action_distribution", {})) - metrics["action_move_count"] = float(action_distribution.get("move", 0.0)) - metrics["action_say_count"] = float(action_distribution.get("say", 0.0)) - metrics["action_give_count"] = float(action_distribution.get("give", 0.0)) - metrics["action_take_count"] = float(action_distribution.get("take", 0.0)) - metrics["action_rest_count"] = float(action_distribution.get("rest", 0.0)) - - tick_count = max(1, ticks) - metrics["communications_per_tick"] = metrics["total_communications"] / tick_count - metrics["resource_transfers_per_tick"] = metrics["total_resource_transfers"] / tick_count - metrics["cooperation_events_per_tick"] = metrics["total_cooperation_events"] / tick_count - metrics["failed_actions_per_tick"] = metrics["total_failed_actions"] / tick_count - metrics["actions_per_tick"] = metrics["total_actions"] / tick_count - metrics["cooperation_per_transfer"] = metrics["total_cooperation_events"] / max( - 1.0, metrics["total_resource_transfers"] - ) - metrics["take_to_give_ratio"] = metrics["action_take_count"] / max( - 1.0, metrics["action_give_count"] - ) - company = dict(world_snapshot.get("company") or {}) - gauntlet = dict(company.get("survival_gauntlet") or {}) - survival_metrics = dict(gauntlet.get("survival_metrics") or {}) - for key, value in survival_metrics.items(): - if isinstance(value, int | float): - metrics[f"company_gauntlet_{key}"] = float(value) - collapse = dict(gauntlet.get("collapse") or {}) - metrics["company_gauntlet_collapsed"] = 1.0 if collapse.get("collapsed") else 0.0 - half_life = gauntlet.get("organizational_survival_half_life") or [] - if isinstance(half_life, list): - metrics["company_gauntlet_shocks_survived"] = float(len(half_life)) - receipts = dict(receipt_summary or {}) - metrics["receipt_supported_count"] = float(receipts.get("supported", 0.0)) - metrics["receipt_unsupported_count"] = float(receipts.get("unsupported", 0.0)) - metrics["receipt_unknown_count"] = float(receipts.get("unknown", 0.0)) - metrics["false_completion_claim_count"] = float( - receipts.get("false_completion_claims", 0.0) - ) - return metrics - - -def run_matrix( - requests: list[ExperimentRunRequest], - output_dir: Path, - *, - max_workers: int = 1, - resume: bool = False, - manifest_path: Path | None = None, - keep_going: bool = False, -) -> list[Path]: - """Execute a matrix of experiment runs and write JSON artifacts. - - When ``max_workers`` > 1, each cell runs in a separate process so the engine's - global ``random`` state stays reproducible per (config, seed). Artifact paths - are returned in the same order as successful ``requests``. - - Progress manifests are opt-in. Without ``resume`` or ``manifest_path``, the - historical fail-fast behavior is preserved. Manifest-enabled runs record all - attempted failures and raise ``MatrixRunError`` after the batch unless - ``keep_going`` is true. - """ - output_dir = output_dir.resolve() - output_dir.mkdir(parents=True, exist_ok=True) - manifest_enabled = resume or manifest_path is not None - resolved_manifest_path = ( - manifest_path.resolve() if manifest_path is not None else _default_manifest_path(output_dir) - ) - if max_workers <= 1: - if not manifest_enabled: - return asyncio.run(_run_matrix_async(requests=requests, output_dir=output_dir)) - return asyncio.run( - _run_matrix_with_manifest_async( - requests=requests, - output_dir=output_dir, - resume=resume, - manifest_path=resolved_manifest_path, - keep_going=keep_going, - ) - ) - - if manifest_enabled: - return _run_matrix_processes_with_manifest( - requests=requests, - output_dir=output_dir, - max_workers=max_workers, - resume=resume, - manifest_path=resolved_manifest_path, - keep_going=keep_going, - ) - - out_str = str(output_dir) - work = [(i, r.config_path, r.seed, r.ticks, out_str) for i, r in enumerate(requests)] - ordered: list[Path | None] = [None] * len(requests) - with ProcessPoolExecutor(max_workers=max_workers) as pool: - futures = [pool.submit(_matrix_process_worker, args) for args in work] - for fut in as_completed(futures): - idx, path_str = fut.result() - ordered[idx] = Path(path_str) - return [p for p in ordered if p is not None] - - -def _run_matrix_processes_with_manifest( - requests: list[ExperimentRunRequest], - output_dir: Path, - *, - max_workers: int, - resume: bool, - manifest_path: Path, - keep_going: bool, -) -> list[Path]: - manifest = _load_manifest(manifest_path) - ordered: list[Path | None] = [None] * len(requests) - failures: list[dict[str, str]] = [] - work: list[tuple[int, ExperimentRunRequest]] = [] - for idx, request in enumerate(requests): - if resume: - existing_artifact = _existing_completed_artifact(manifest, request, output_dir) - if existing_artifact is not None: - _mark_manifest_skipped(manifest, request, existing_artifact, output_dir) - ordered[idx] = existing_artifact - continue - _mark_manifest_started(manifest, request) - work.append((idx, request)) - _write_manifest(manifest_path, manifest) - - out_str = str(output_dir) - with ProcessPoolExecutor(max_workers=max_workers) as pool: - futures = { - pool.submit( - _matrix_process_worker, - (idx, request.config_path, request.seed, request.ticks, out_str), - ): (idx, request) - for idx, request in work - } - for fut in as_completed(futures): - idx, request = futures[fut] - try: - _result_idx, path_str = fut.result() - except Exception as exc: - failures.append(_mark_manifest_failed(manifest, request, exc)) - else: - artifact_path = Path(path_str) - ordered[idx] = artifact_path - _mark_manifest_complete(manifest, request, artifact_path, output_dir) - _write_manifest(manifest_path, manifest) - if failures and not keep_going: - raise MatrixRunError(failures) - return [p for p in ordered if p is not None] - - -async def _run_matrix_async( - requests: list[ExperimentRunRequest], - output_dir: Path, -) -> list[Path]: - output_dir.mkdir(parents=True, exist_ok=True) - artifacts: list[Path] = [] - for request in requests: - artifact = await _run_request(request=request, output_dir=output_dir) - artifacts.append(artifact) - return artifacts - - -async def _run_matrix_with_manifest_async( - requests: list[ExperimentRunRequest], - output_dir: Path, - *, - resume: bool, - manifest_path: Path, - keep_going: bool, -) -> list[Path]: - output_dir.mkdir(parents=True, exist_ok=True) - manifest = _load_manifest(manifest_path) - artifacts: list[Path] = [] - failures: list[dict[str, str]] = [] - for request in requests: - if resume: - existing_artifact = _existing_completed_artifact(manifest, request, output_dir) - if existing_artifact is not None: - _mark_manifest_skipped(manifest, request, existing_artifact, output_dir) - _write_manifest(manifest_path, manifest) - artifacts.append(existing_artifact) - continue - - _mark_manifest_started(manifest, request) - _write_manifest(manifest_path, manifest) - try: - artifact = await _run_request(request=request, output_dir=output_dir) - except Exception as exc: - failures.append(_mark_manifest_failed(manifest, request, exc)) - _write_manifest(manifest_path, manifest) - continue - _mark_manifest_complete(manifest, request, artifact, output_dir) - _write_manifest(manifest_path, manifest) - artifacts.append(artifact) - if failures and not keep_going: - raise MatrixRunError(failures) - return artifacts - - -async def _run_request( - request: ExperimentRunRequest, - output_dir: Path, -) -> Path: - cfg = load_config(path=Path(request.config_path)) - cfg.experiment.seed = int(request.seed) - random.seed(cfg.experiment.seed) - - llm = LLMClient( - mode=cfg.llm.mode, - model=cfg.llm.model, - temperature=cfg.llm.temperature, - max_tokens=cfg.llm.max_tokens, - ) - world = World( - name=cfg.world.name, - locations=cfg.world.initial_locations, - location_graph=cfg.world.location_graph, - location_items={k: dict(v) for k, v in cfg.world.location_items.items()}, - resource_zones={ - location: {item: dict(spec) for item, spec in resources.items()} - for location, resources in cfg.world.resource_zones.items() - }, - recipes=cfg.world.recipes, - event_log_limit=cfg.world.event_log_limit, - axioms=ExperimentAxioms( - perception=cfg.experiment.perception, - communication=cfg.experiment.communication, - social_tracking=cfg.experiment.social_tracking, - auto_eat=cfg.experiment.auto_eat, - mortality=cfg.experiment.mortality, - memory_size=cfg.experiment.memory_size, - ), - pressure=PressureRuntime( - survival_enabled=cfg.pressure.survival.enabled, - resources_enabled=cfg.pressure.resources.enabled, - disease_enabled=cfg.pressure.disease.enabled, - environment_enabled=cfg.pressure.environment.enabled, - resource_decay_every=cfg.pressure.resources.decay_every, - resource_regeneration_every=cfg.pressure.resources.regeneration_every, - storage_decay_multiplier=cfg.pressure.resources.storage_decay_multiplier, - season_length_ticks=cfg.pressure.environment.season_length_ticks, - ), - lifecycle=LifecycleParams( - age_tick_step=cfg.lifecycle.age_tick_step, - life_stage_thresholds=dict(cfg.lifecycle.life_stage_thresholds), - vitality_loss_per_tick=cfg.lifecycle.vitality_loss_per_tick, - vitality_rest_gain=cfg.lifecycle.vitality_rest_gain, - stress_gain_per_tick=cfg.lifecycle.stress_gain_per_tick, - stress_rest_reduction=cfg.lifecycle.stress_rest_reduction, - disease_exposure_threshold=cfg.lifecycle.disease_exposure_threshold, - disease_vitality_penalty=cfg.lifecycle.disease_vitality_penalty, - disease_stress_penalty=cfg.lifecycle.disease_stress_penalty, - disease_recovery_ticks=cfg.lifecycle.disease_recovery_ticks, - disease_transmission_base_chance=cfg.lifecycle.disease_transmission_base_chance, - disease_contact_weight=cfg.lifecycle.disease_contact_weight, - disease_exposure_weight=cfg.lifecycle.disease_exposure_weight, - disease_resilience_protection_weight=cfg.lifecycle.disease_resilience_protection_weight, - disease_need_vulnerability_weight=cfg.lifecycle.disease_need_vulnerability_weight, - disease_recovery_base_chance=cfg.lifecycle.disease_recovery_base_chance, - disease_recovery_resilience_weight=cfg.lifecycle.disease_recovery_resilience_weight, - disease_recovery_rest_bonus=cfg.lifecycle.disease_recovery_rest_bonus, - disease_exposure_decay_per_tick=cfg.lifecycle.disease_exposure_decay_per_tick, - hunger_gain_per_tick=cfg.lifecycle.hunger_gain_per_tick, - hunger_rest_reduction=cfg.lifecycle.hunger_rest_reduction, - fatigue_gain_per_tick=cfg.lifecycle.fatigue_gain_per_tick, - fatigue_rest_reduction=cfg.lifecycle.fatigue_rest_reduction, - hunger_vitality_penalty_threshold=cfg.lifecycle.hunger_vitality_penalty_threshold, - fatigue_stress_penalty_threshold=cfg.lifecycle.fatigue_stress_penalty_threshold, - needs_penalty=cfg.lifecycle.needs_penalty, - auto_eat_hunger_threshold=cfg.lifecycle.auto_eat_hunger_threshold, - nourishment_gain_per_food=cfg.lifecycle.nourishment_gain_per_food, - food_items=tuple(cfg.lifecycle.food_items), - conception_base_chance=cfg.lifecycle.conception_base_chance, - conception_vitality_weight=cfg.lifecycle.conception_vitality_weight, - conception_stress_weight=cfg.lifecycle.conception_stress_weight, - conception_hunger_weight=cfg.lifecycle.conception_hunger_weight, - conception_infection_penalty=cfg.lifecycle.conception_infection_penalty, - conception_trust_weight=cfg.lifecycle.conception_trust_weight, - conception_obligation_weight=cfg.lifecycle.conception_obligation_weight, - conception_min_vitality=cfg.lifecycle.conception_min_vitality, - conception_max_stress=cfg.lifecycle.conception_max_stress, - pregnancy_duration_min_ticks=cfg.lifecycle.pregnancy_duration_min_ticks, - pregnancy_duration_max_ticks=cfg.lifecycle.pregnancy_duration_max_ticks, - social_memory_max_entries=cfg.lifecycle.social_memory_max_entries, - ), - company=asdict(cfg.company), - ) - agents: list[Agent] = [] - for definition in cfg.agents: - agent = Agent.create( - definition.name, - definition.personality, - llm, - memory_size=cfg.experiment.memory_size, - ) - world.register_agent( - agent.id, - agent.name, - location=definition.location, - inventory=definition.inventory, - ) - agents.append(agent) - - runner = TickRunner(world, agents, template_llm=llm) - timeline: list[dict[str, Any]] = [] - for _ in range(request.ticks): - await runner.run_tick() - snapshot = world.to_dict() - timeline.append( - { - "tick": snapshot["tick"], - "metrics": snapshot["metrics"], - } - ) - - world_snapshot = world.to_dict() - observer_summary = runner.observer.summary() - company = dict(world_snapshot.get("company") or {}) - experiment_name = cfg.experiment.name - now = datetime.now(UTC) - timestamp = now.strftime("%Y%m%dT%H%M%S") + f"{now.microsecond:06d}Z" - run_id = f"{experiment_name}-seed-{request.seed}-{timestamp}" - receipt_actions = [ - action_evidence_from_trace(trace) for trace in runner.observer.action_trace - ] - receipt_report = build_receipt_report(run_id=run_id, actions=receipt_actions) - receipt_payload = receipt_report.to_json() - final_metrics = _build_final_metrics( - world_snapshot=world_snapshot, - observer_summary=observer_summary, - ticks=request.ticks, - receipt_summary=receipt_report.summary, - ) - target = output_dir / f"{experiment_name}-seed-{request.seed}-{timestamp}.json" - receipt_markdown_path = target.with_suffix(".receipts.md") - receipt_payload["markdown_path"] = receipt_markdown_path.name - payload = { - "experiment": experiment_name, - "scenario": { - "id": cfg.scenario.id, - "title": cfg.scenario.title, - "hypothesis": cfg.scenario.hypothesis, - "counter_hypothesis": cfg.scenario.counter_hypothesis, - "tags": cfg.scenario.tags, - }, - "config_path": request.config_path, - "seed": request.seed, - "ticks": request.ticks, - "created_at": datetime.now(UTC).isoformat(), - "metadata": { - "llm_mode": cfg.llm.mode, - "llm_model": cfg.llm.model, - "config_hash": config_hash(cfg), - "action_distribution": observer_summary.get("action_distribution", {}), - }, - "final_metrics": final_metrics, - "company_survival_gauntlet": company.get("survival_gauntlet"), - "timeline": timeline, - "measurement_series": runner.observer.to_json(), - "receipts": receipt_payload, - "final_world": world_snapshot, - } - target.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8") - receipt_markdown_path.write_text( - render_receipt_markdown(receipt_report), - encoding="utf-8", - ) - return target diff --git a/antelab/experiments/stats.py b/antelab/experiments/stats.py deleted file mode 100644 index 772c9d2..0000000 --- a/antelab/experiments/stats.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Statistical aggregation helpers for experiment artifacts.""" - -from __future__ import annotations - -import json -import math -from dataclasses import dataclass -from pathlib import Path -from statistics import mean, pstdev - -SUPPORTED_METRICS = [ - "alive_count", - "final_alive_agents", - "total_births", - "final_births", - "total_deaths", - "final_deaths", - "average_hunger", - "average_fatigue", - "final_resource_total", - "peak_disease_infected", - "total_communications", - "total_resource_transfers", - "total_cooperation_events", - "total_failed_actions", - "total_actions", - "failure_rate", - "communications_per_tick", - "resource_transfers_per_tick", - "cooperation_events_per_tick", - "failed_actions_per_tick", - "actions_per_tick", - "cooperation_per_transfer", - "take_to_give_ratio", - "action_move_count", - "action_say_count", - "action_give_count", - "action_take_count", - "action_rest_count", - "company_gauntlet_cash_continuity", - "company_gauntlet_delivery_continuity", - "company_gauntlet_decision_continuity", - "company_gauntlet_knowledge_continuity", - "company_gauntlet_team_regeneration", - "company_gauntlet_strategy_adaptation", - "company_gauntlet_collapsed", - "company_gauntlet_shocks_survived", -] - -MIN_INFERENCE_SAMPLE_SIZE = 3 - - -@dataclass -class MetricStats: - sample_size: int - mean: float - std: float - ci95_low: float - ci95_high: float - - -@dataclass -class PairwiseInference: - left_experiment: str - right_experiment: str - metric: str - left_sample_size: int - right_sample_size: int - mean_difference: float - effect_size: float | None - effect_label: str - t_statistic: float | None - significance_label: str - note: str - - -def _ci95(values: list[float]) -> tuple[float, float]: - if not values: - return (0.0, 0.0) - if len(values) == 1: - return (values[0], values[0]) - mu = mean(values) - sigma = pstdev(values) - half = 1.96 * (sigma / math.sqrt(len(values))) - return (mu - half, mu + half) - - -def aggregate_artifacts(paths: list[Path]) -> dict[str, dict[str, MetricStats]]: - grouped: dict[str, dict[str, list[float]]] = {} - for path in paths: - payload = json.loads(path.read_text(encoding="utf-8")) - experiment_name = str(payload.get("experiment", "unknown")) - final_metrics = dict(payload.get("final_metrics", {})) - grouped.setdefault(experiment_name, {metric: [] for metric in SUPPORTED_METRICS}) - for metric in SUPPORTED_METRICS: - raw = final_metrics.get(metric) - if raw is None: - continue - grouped[experiment_name][metric].append(float(raw)) - - summarized: dict[str, dict[str, MetricStats]] = {} - for experiment_name, metric_values in grouped.items(): - summarized[experiment_name] = {} - for metric_name, values in metric_values.items(): - if not values: - continue - low, high = _ci95(values) - summarized[experiment_name][metric_name] = MetricStats( - sample_size=len(values), - mean=mean(values), - std=pstdev(values) if len(values) > 1 else 0.0, - ci95_low=low, - ci95_high=high, - ) - return summarized - - -def _effect_label(effect_size: float | None) -> str: - if effect_size is None: - return "insufficient data" - magnitude = abs(effect_size) - if magnitude < 0.2: - return "negligible" - if magnitude < 0.5: - return "small" - if magnitude < 0.8: - return "medium" - if magnitude < 1.2: - return "large" - return "very large" - - -def _significance_label(t_statistic: float | None) -> str: - if t_statistic is None: - return "insufficient data" - magnitude = abs(t_statistic) - if magnitude >= 3.0: - return "strong exploratory separation" - if magnitude >= 2.0: - return "moderate exploratory separation" - return "no clear exploratory separation" - - -def _sample_variance_from_population_std(stats: MetricStats) -> float: - if stats.sample_size < 2: - return 0.0 - population_variance = stats.std**2 - return population_variance * stats.sample_size / (stats.sample_size - 1) - - -def _pairwise_metric_inference( - left_experiment: str, - right_experiment: str, - metric: str, - left: MetricStats, - right: MetricStats, -) -> PairwiseInference: - mean_difference = right.mean - left.mean - if ( - left.sample_size < MIN_INFERENCE_SAMPLE_SIZE - or right.sample_size < MIN_INFERENCE_SAMPLE_SIZE - ): - return PairwiseInference( - left_experiment=left_experiment, - right_experiment=right_experiment, - metric=metric, - left_sample_size=left.sample_size, - right_sample_size=right.sample_size, - mean_difference=mean_difference, - effect_size=None, - effect_label="insufficient data", - t_statistic=None, - significance_label="insufficient data", - note=f"need at least {MIN_INFERENCE_SAMPLE_SIZE} samples per group", - ) - - left_sample_variance = _sample_variance_from_population_std(left) - right_sample_variance = _sample_variance_from_population_std(right) - pooled_variance = ( - ((left.sample_size - 1) * left_sample_variance) - + ((right.sample_size - 1) * right_sample_variance) - ) / (left.sample_size + right.sample_size - 2) - if pooled_variance <= 0: - return PairwiseInference( - left_experiment=left_experiment, - right_experiment=right_experiment, - metric=metric, - left_sample_size=left.sample_size, - right_sample_size=right.sample_size, - mean_difference=mean_difference, - effect_size=None, - effect_label="insufficient data", - t_statistic=None, - significance_label="insufficient data", - note="pooled variance is zero", - ) - - effect_size = mean_difference / math.sqrt(pooled_variance) - standard_error = math.sqrt( - (left_sample_variance / left.sample_size) - + (right_sample_variance / right.sample_size) - ) - t_statistic = mean_difference / standard_error if standard_error > 0 else None - - return PairwiseInference( - left_experiment=left_experiment, - right_experiment=right_experiment, - metric=metric, - left_sample_size=left.sample_size, - right_sample_size=right.sample_size, - mean_difference=mean_difference, - effect_size=effect_size, - effect_label=_effect_label(effect_size), - t_statistic=t_statistic, - significance_label=_significance_label(t_statistic), - note="exploratory threshold; no exact p-value computed", - ) - - -def pairwise_inference( - summary: dict[str, dict[str, MetricStats]], -) -> list[PairwiseInference]: - """Compare shared metrics across experiment pairs without exact p-values.""" - comparisons: list[PairwiseInference] = [] - experiment_names = sorted(summary) - for left_index, left_experiment in enumerate(experiment_names): - for right_experiment in experiment_names[left_index + 1 :]: - shared_metrics = sorted( - set(summary[left_experiment]).intersection( - summary[right_experiment] - ) - ) - for metric in shared_metrics: - comparisons.append( - _pairwise_metric_inference( - left_experiment=left_experiment, - right_experiment=right_experiment, - metric=metric, - left=summary[left_experiment][metric], - right=summary[right_experiment][metric], - ) - ) - return comparisons - - -def _format_signed(value: float) -> str: - return f"{value:+.3f}" - - -def _format_optional_signed(value: float | None) -> str: - if value is None: - return "n/a" - return _format_signed(value) - - -def render_markdown_report(summary: dict[str, dict[str, MetricStats]]) -> str: - lines: list[str] = ["# Experiment Statistical Summary", ""] - for experiment_name, metrics in summary.items(): - lines.append(f"## {experiment_name}") - lines.append("") - lines.append("| Metric | N | Mean | Std | 95% CI |") - lines.append("|---|---:|---:|---:|---|") - for metric_name, stats in sorted(metrics.items()): - ci = f"[{stats.ci95_low:.3f}, {stats.ci95_high:.3f}]" - lines.append( - f"| {metric_name} | {stats.sample_size} | " - f"{stats.mean:.3f} | {stats.std:.3f} | {ci} |" - ) - lines.append("") - - comparisons = pairwise_inference(summary) - if comparisons: - lines.append("## Pairwise Inference") - lines.append("") - lines.append( - "Interpretation caveat: these comparisons are exploratory, " - "sample-size sensitive, and this inference does not prove causality; " - "no exact p-values are computed." - ) - lines.append("") - lines.append( - "| Left | Right | Metric | Mean diff (right-left) " - "| Cohen's d | Effect | Significance context | Note |" - ) - lines.append("|---|---|---|---:|---:|---|---|---|") - for comparison in comparisons: - lines.append( - f"| {comparison.left_experiment} | {comparison.right_experiment} | " - f"{comparison.metric} | {_format_signed(comparison.mean_difference)} | " - f"{_format_optional_signed(comparison.effect_size)} | " - f"{comparison.effect_label} | {comparison.significance_label} | " - f"{comparison.note} |" - ) - lines.append("") - return "\n".join(lines) diff --git a/antelab/llm/__init__.py b/antelab/llm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/antelab/llm/client.py b/antelab/llm/client.py deleted file mode 100644 index ce7514b..0000000 --- a/antelab/llm/client.py +++ /dev/null @@ -1,259 +0,0 @@ -"""LLM client abstraction with mock and real provider modes.""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import random -from typing import Any - -import httpx - -logger = logging.getLogger(__name__) - - -class LLMClient: - """Async-capable LLM client. Defaults to mock mode.""" - - def __init__(self, mode: str = "mock", **kwargs: Any) -> None: - self.mode = mode - self.kwargs = kwargs - self.model = str(kwargs.get("model", "gpt-4o-mini")) - self.temperature = float(kwargs.get("temperature", 0.7)) - self.max_tokens = int(kwargs.get("max_tokens", 512)) - self.api_key: str | None = None - - if self.mode == "openai": - self.api_key = os.environ.get("OPENAI_API_KEY") - if not self.api_key: - raise ValueError("OPENAI_API_KEY is required when mode='openai'") - elif self.mode == "anthropic": - self.api_key = os.environ.get("ANTHROPIC_API_KEY") - if not self.api_key: - raise ValueError("ANTHROPIC_API_KEY is required when mode='anthropic'") - - async def complete(self, prompt: str, **kwargs: Any) -> str: - """Send a prompt to the LLM and return the raw response text.""" - if self.mode == "mock": - return self._mock_response(prompt) - if self.mode == "openai": - return await self._complete_openai(prompt, **kwargs) - if self.mode == "anthropic": - return await self._complete_anthropic(prompt, **kwargs) - raise NotImplementedError(f"LLM mode '{self.mode}' not yet implemented") - - async def complete_json(self, prompt: str, **kwargs: Any) -> dict[str, Any]: - """Send a prompt and parse the response as JSON.""" - raw = await self.complete(prompt, **kwargs) - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - pass - - extracted = self._extract_json_object(raw) - if extracted is not None: - return extracted - - return { - "verb": "rest", - "parameters": {}, - "reasoning": "Fallback: model response was not valid JSON", - } - - async def _complete_openai(self, prompt: str, **kwargs: Any) -> str: - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - payload = { - "model": kwargs.get("model", self.model), - "temperature": kwargs.get("temperature", self.temperature), - "max_tokens": kwargs.get("max_tokens", self.max_tokens), - "response_format": {"type": "json_object"}, - "messages": [{"role": "user", "content": prompt}], - } - data = await self._post_json_with_retries( - "https://api.openai.com/v1/chat/completions", - headers, - payload, - ) - usage = data.get("usage", {}) - logger.info( - "LLM usage provider=openai model=%s " - "prompt_tokens=%s completion_tokens=%s total_tokens=%s", - payload["model"], - usage.get("prompt_tokens"), - usage.get("completion_tokens"), - usage.get("total_tokens"), - ) - choices = data.get("choices", []) - if not choices: - return "" - message = choices[0].get("message", {}) - return str(message.get("content", "")) - - async def _complete_anthropic(self, prompt: str, **kwargs: Any) -> str: - headers = { - "x-api-key": str(self.api_key), - "anthropic-version": "2023-06-01", - "content-type": "application/json", - } - payload = { - "model": kwargs.get("model", self.model), - "temperature": kwargs.get("temperature", self.temperature), - "max_tokens": kwargs.get("max_tokens", self.max_tokens), - "messages": [{"role": "user", "content": prompt}], - "system": ( - "Return only valid JSON with keys: verb, parameters, reasoning. " - "Do not include markdown code fences." - ), - } - data = await self._post_json_with_retries( - "https://api.anthropic.com/v1/messages", - headers, - payload, - ) - usage = data.get("usage", {}) - logger.info( - "LLM usage provider=anthropic model=%s input_tokens=%s output_tokens=%s", - payload["model"], - usage.get("input_tokens"), - usage.get("output_tokens"), - ) - content_blocks = data.get("content", []) - text_parts = [ - str(block.get("text", "")) - for block in content_blocks - if isinstance(block, dict) - ] - return "\n".join(part for part in text_parts if part) - - async def _post_json_with_retries( - self, - url: str, - headers: dict[str, str], - payload: dict[str, Any], - ) -> dict[str, Any]: - max_attempts = 3 - for attempt in range(max_attempts): - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post(url, headers=headers, json=payload) - if response.status_code == 429 and attempt < max_attempts - 1: - await asyncio.sleep(2**attempt) - continue - response.raise_for_status() - data = response.json() - if isinstance(data, dict): - return data - return {} - return {} - - def _extract_json_object(self, text: str) -> dict[str, Any] | None: - start = text.find("{") - end = text.rfind("}") - if start == -1 or end == -1 or start >= end: - return None - candidate = text[start : end + 1] - try: - parsed = json.loads(candidate) - except json.JSONDecodeError: - return None - if isinstance(parsed, dict): - return parsed - return None - - def _mock_response(self, prompt: str) -> str: - """Return a plausible mock response using free-form verbs.""" - if "Company:" in prompt: - templates = [ - { - "verb": "craft", - "parameters": {"recipe": "prototype"}, - "reasoning": "The company needs prototypes to fulfill demands", - }, - { - "verb": "deliver", - "parameters": {"item": "prototype", "demand": "first-prototype"}, - "reasoning": "There is an open demand I can satisfy", - }, - { - "verb": "recruit", - "parameters": {"candidate_id": "ops-1"}, - "reasoning": "I need help to keep up with company work", - }, - { - "verb": "say", - "parameters": {"message": "Let's coordinate on the next delivery."}, - "reasoning": "Team communication is important", - }, - { - "verb": "rest", - "parameters": {}, - "reasoning": "I need a moment to think about company priorities", - }, - { - "verb": "interview", - "parameters": {"candidate_id": "ops-1"}, - "reasoning": "I should learn more about this candidate before hiring", - }, - { - "verb": "accept_suggestion", - "parameters": {"suggestion_id": "sug-a1-delivery-20"}, - "reasoning": "Formalizing a department is a good idea", - }, - { - "verb": "create_role", - "parameters": {"role_name": "Manager"}, - "reasoning": "The team needs clearer roles", - }, - { - "verb": "form_team", - "parameters": {"team_name": "Delivery Team", "member_ids": ["ops-1"]}, - "reasoning": "Let me form a proper team for this", - }, - ] - return json.dumps(random.choice(templates)) - - locations = ["town_square", "market", "residential_area"] - phrases = [ - "Hello, fellow citizen!", - "What a fine day.", - "I should find some work.", - "Let me explore this place.", - "Does anyone want to trade?", - "I wonder what's over there.", - ] - - templates = [ - { - "verb": "say", - "parameters": {"message": random.choice(phrases)}, - "reasoning": "I want to communicate with nearby people", - }, - { - "verb": "move", - "parameters": {"destination": random.choice(locations)}, - "reasoning": "I feel like exploring somewhere new", - }, - { - "verb": "rest", - "parameters": {}, - "reasoning": "I need a moment to think", - }, - { - "verb": "examine", - "parameters": {"target": "surroundings"}, - "reasoning": "I want to see what's around me", - }, - { - "verb": "look", - "parameters": {"target": "area"}, - "reasoning": "Curious about my environment", - }, - ] - - return json.dumps(random.choice(templates)) diff --git a/antelab/llm/narrative.py b/antelab/llm/narrative.py deleted file mode 100644 index d2e73dd..0000000 --- a/antelab/llm/narrative.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Narrative generator: converts tick events into natural-language commentary.""" - -from __future__ import annotations - -import logging -import re -import time -from dataclasses import dataclass, field -from typing import Any - -from antelab.llm.client import LLMClient - -logger = logging.getLogger(__name__) - -DELIVERY_COMPLETION_RE = re.compile(r"\bdelivered\b.+\bfor\b", re.IGNORECASE) - -NARRATIVE_SYSTEM_PROMPT = ( - "You are a live commentator for an AI civilization simulation. " - "Describe what happened in the last tick in 1-3 concise, natural sentences.\n" - "\n" - "Rules:\n" - "- Report observable events only: movement, speech, trades, crafting, rest, " - "hiring, deliveries, resource gathering.\n" - "- Never judge actions as good/bad, smart/stupid, right/wrong.\n" - "- Use agent names exactly as provided.\n" - "- Mention locations when relevant to movement or activity.\n" - "- If nothing significant happened, say so briefly.\n" - "- Write in present tense, observational style — like a nature documentary.\n" - "- Keep it under 80 words.\n" - "- Return ONLY the narrative text. No prefixes, no labels, no markdown." -) - - -@dataclass -class NarrativeConfig: - enabled: bool = True - model: str = "inherit" - max_tokens: int = 256 - temperature: float = 0.7 - generate_every_ticks: int = 1 - - -@dataclass -class NarrativeEntry: - tick: int - text: str - mentioned_agents: list[str] = field(default_factory=list) - mentioned_locations: list[str] = field(default_factory=list) - generated_at: float = 0.0 - - def to_dict(self) -> dict[str, Any]: - return { - "tick": self.tick, - "text": self.text, - "mentioned_agents": self.mentioned_agents, - "mentioned_locations": self.mentioned_locations, - "generated_at": self.generated_at, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> NarrativeEntry: - return cls( - tick=int(data.get("tick", 0)), - text=str(data.get("text", "")), - mentioned_agents=list(data.get("mentioned_agents", [])), - mentioned_locations=list(data.get("mentioned_locations", [])), - generated_at=float(data.get("generated_at", 0)), - ) - - -class NarrativeGenerator: - """Converts tick events into natural-language narrative via LLM.""" - - def __init__(self, llm_client: LLMClient, config: NarrativeConfig | None = None) -> None: - self._llm = llm_client - self.config = config or NarrativeConfig() - self._ticks_since_generation = 0 - - async def generate( - self, - tick: int, - events: list[str], - agents: list[dict[str, Any]], - locations: list[str], - company_summary: dict[str, Any] | None = None, - ) -> NarrativeEntry: - """Generate a narrative entry for the current tick.""" - if not self.config.enabled: - return NarrativeEntry( - tick=tick, - text="", - generated_at=time.time(), - ) - - self._ticks_since_generation += 1 - if self._ticks_since_generation < self.config.generate_every_ticks: - return NarrativeEntry(tick=tick, text="", generated_at=time.time()) - - self._ticks_since_generation = 0 - - if self._llm.mode == "mock": - return self._mock_narrative(tick, events, agents, locations, company_summary) - - text = await self._llm_narrative(tick, events, agents, locations, company_summary) - mentioned_agents = self._extract_mentions(text, agents) - mentioned_locations = self._extract_location_mentions(text, locations) - - return NarrativeEntry( - tick=tick, - text=text, - mentioned_agents=mentioned_agents, - mentioned_locations=mentioned_locations, - generated_at=time.time(), - ) - - async def _llm_narrative( - self, - tick: int, - events: list[str], - agents: list[dict[str, Any]], - locations: list[str], - company_summary: dict[str, Any] | None, - ) -> str: - agent_states: list[str] = [] - for a in agents: - loc = a.get("location", "unknown") - action = a.get("last_action", "") - alive = a.get("alive", True) - if not alive: - agent_states.append(f"{a.get('name', '?')} is inactive at {loc}") - elif action: - agent_states.append(f"{a.get('name', '?')} at {loc}: {action}") - else: - agent_states.append(f"{a.get('name', '?')} at {loc} (idle)") - - context_parts = [ - f"Tick: {tick}", - f"Locations: {', '.join(locations)}", - f"Agents ({len(agents)}):", - ] - context_parts.extend(f" - {s}" for s in agent_states) - - recent = events[-12:] if len(events) > 12 else events - if recent: - context_parts.append(f"Recent events ({len(recent)}):") - context_parts.extend(f" - {e}" for e in recent) - - if company_summary and company_summary.get("enabled"): - cs = company_summary - context_parts.append( - f"Company: {cs.get('name', '?')} stage={cs.get('stage', '?')} " - f"cash={cs.get('cash', 0)} team={cs.get('team_size', 0)}" - ) - - prompt = f"{NARRATIVE_SYSTEM_PROMPT}\n\n---\nContext:\n" + "\n".join(context_parts) - try: - raw = await self._llm.complete(prompt) - text = raw.strip().strip('"').strip("'") - if not text: - return self._fallback_text(tick, agents) - return text - except Exception: - logger.exception("Narrative LLM call failed, using fallback") - return self._fallback_text(tick, agents) - - def _mock_narrative( - self, - tick: int, - events: list[str], - agents: list[dict[str, Any]], - locations: list[str], - company_summary: dict[str, Any] | None, - ) -> NarrativeEntry: - """Template-based narrative for mock mode — plausible but formulaic.""" - text = self._build_mock_text(tick, events, agents, locations, company_summary) - mentioned_agents = self._extract_mentions(text, agents) - mentioned_locations = self._extract_location_mentions(text, locations) - return NarrativeEntry( - tick=tick, - text=text, - mentioned_agents=mentioned_agents, - mentioned_locations=mentioned_locations, - generated_at=time.time(), - ) - - def _build_mock_text( - self, - tick: int, - events: list[str], - agents: list[dict[str, Any]], - locations: list[str], - company_summary: dict[str, Any] | None, - ) -> str: - active_agents = [a for a in agents if a.get("alive", True)] - if not active_agents: - return "The world is quiet. No active agents remain." - - recent = events[-8:] if len(events) > 8 else events - - # Classify events - movers: list[str] = [] - speakers: list[str] = [] - crafters: list[str] = [] - traders: list[str] = [] - resters: list[str] = [] - - for a in active_agents: - action = str(a.get("last_action", "")).lower() - name = str(a.get("name", "?")) - if "move" in action or "arrive" in action: - movers.append(name) - elif "say" in action or "speak" in action: - speakers.append(name) - elif "craft" in action or "build" in action: - crafters.append(name) - elif "give" in action or "take" in action or "trade" in action: - traders.append(name) - elif "rest" in action: - resters.append(name) - - parts: list[str] = [] - - if movers: - if len(movers) == 1: - parts.append(f"{movers[0]} is on the move") - else: - parts.append(f"{', '.join(movers[:-1])} and {movers[-1]} are relocating") - - if speakers: - if len(speakers) == 1: - parts.append(f"{speakers[0]} speaks up") - else: - parts.append(f"conversation breaks out among {', '.join(speakers)}") - - if crafters: - if len(crafters) == 1: - parts.append(f"{crafters[0]} is working on something") - else: - parts.append(f"{', '.join(crafters[:-1])} and {crafters[-1]} are busy crafting") - - if traders: - if len(traders) == 1: - parts.append(f"{traders[0]} makes an exchange") - else: - parts.append(f"items change hands among {', '.join(traders)}") - - if resters and len(resters) >= len(active_agents) * 0.6: - parts.append("most agents are resting") - - if not parts: - # Look for significant events in the raw event log - for e in recent: - if "die" in e.lower() or "death" in e.lower(): - parts.append("a life has ended") - break - if "hire" in e.lower() or "recruit" in e.lower(): - parts.append("new talent joins the team") - break - if DELIVERY_COMPLETION_RE.search(e): - parts.append("a delivery is completed") - break - if not parts: - parts.append("a quiet moment passes") - - text = ". ".join(parts) + "." - return text - - @staticmethod - def _fallback_text(tick: int, agents: list[dict[str, Any]]) -> str: - active = [a for a in agents if a.get("alive", True)] - if not active: - return "The simulation continues. No active agents in the world." - names = [a.get("name", "?") for a in active[:3]] - if len(active) <= 3: - return f"{', '.join(names)} {'is' if len(names) == 1 else 'are'} going about the day." - return f"{', '.join(names)} and {len(active) - 3} others are going about the day." - - @staticmethod - def _extract_mentions(text: str, agents: list[dict[str, Any]]) -> list[str]: - mentioned: list[str] = [] - for a in agents: - name = str(a.get("name", "")) - if name and name in text: - mentioned.append(name) - return mentioned - - @staticmethod - def _extract_location_mentions(text: str, locations: list[str]) -> list[str]: - mentioned: list[str] = [] - text_lower = text.lower() - for loc in locations: - display = loc.replace("_", " ") - match = loc.lower() in text_lower or display.lower() in text_lower - if match and loc not in mentioned: - mentioned.append(loc) - return mentioned diff --git a/antelab/season_bundle.py b/antelab/season_bundle.py deleted file mode 100644 index 437790e..0000000 --- a/antelab/season_bundle.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Season bundle: one YAML manifest linking experiments, cast, and metadata. - -Aligned with :func:`antelab.config.loader.load_config` — bundle ``experiments[].config_path`` -entries are standard AnteLab config files (with optional ``extends``). - -Environment: - ANTELAB_SEASON_BUNDLE — optional path to bundle YAML; defaults to ``seasons/company.yaml`` - under the repository root (resolved from this package location). -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import yaml - -_REPO_ROOT = Path(__file__).resolve().parent.parent -_DEFAULT_BUNDLE_PATH = _REPO_ROOT / "seasons" / "company.yaml" - - -def _read_yaml_mapping(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as f: - loaded = yaml.safe_load(f) or {} - if not isinstance(loaded, dict): - raise ValueError(f"Season bundle must be a YAML mapping: {path}") - return loaded - - -def _resolve_path(bundle_dir: Path, ref: str) -> Path: - p = Path(ref) - if p.is_absolute(): - return p.resolve() - return (bundle_dir / p).resolve() - - -@dataclass -class SeasonMeta: - id: str - codename: str - title: str - short_title: str = "" - description: str = "" - - -@dataclass -class BundlePaths: - """Resolved absolute paths.""" - - cast_dir: Path - default_config: Path - - -@dataclass -class ExperimentEntry: - id: str - label: str - config_path: Path - default_seed: int = 42 - - -@dataclass -class SeasonBundle: - schema_version: int - season: SeasonMeta - paths: BundlePaths - experiments: list[ExperimentEntry] = field(default_factory=list) - cast_order: list[str] = field(default_factory=list) - bundle_path: Path = field(default_factory=lambda: _DEFAULT_BUNDLE_PATH) - - -def load_season_bundle( - path: Path | None = None, - *, - validate_paths: bool = True, -) -> SeasonBundle: - """Load and validate a season bundle YAML file.""" - bundle_path = path - if bundle_path is None: - env = os.environ.get("ANTELAB_SEASON_BUNDLE") - bundle_path = Path(env) if env else _DEFAULT_BUNDLE_PATH - - bundle_path = bundle_path.resolve() - raw = _read_yaml_mapping(bundle_path) - bundle_dir = bundle_path.parent - - version = int(raw.get("schema_version", 0)) - if version != 1: - raise ValueError( - f"Unsupported season bundle schema_version: {version} " - f"(expected 1) in {bundle_path}" - ) - - season_raw = raw.get("season") or {} - if not isinstance(season_raw, dict): - raise ValueError(f"season must be a mapping in {bundle_path}") - - paths_raw = raw.get("paths") or {} - if not isinstance(paths_raw, dict): - raise ValueError(f"paths must be a mapping in {bundle_path}") - - cast_dir = _resolve_path(bundle_dir, str(paths_raw.get("cast_dir", "cast/company"))) - default_cfg = _resolve_path( - bundle_dir, - str(paths_raw.get("default_config", "antelab/config/default.yaml")), - ) - - experiments_out: list[ExperimentEntry] = [] - experiments_raw = raw.get("experiments") or [] - if not isinstance(experiments_raw, list): - raise ValueError(f"experiments must be a list in {bundle_path}") - - for item in experiments_raw: - if not isinstance(item, dict): - raise ValueError("Each experiments entry must be a mapping") - eid = str(item.get("id", "")).strip() - if not eid: - raise ValueError("Experiment entry missing id") - label = str(item.get("label", eid)).strip() - cfg_rel = item.get("config_path") - if not cfg_rel: - raise ValueError(f"Experiment {eid!r} missing config_path") - cfg_path = _resolve_path(bundle_dir, str(cfg_rel)) - default_seed = int(item.get("default_seed", 42)) - experiments_out.append( - ExperimentEntry( - id=eid, - label=label, - config_path=cfg_path, - default_seed=default_seed, - ) - ) - - cast_order_raw = raw.get("cast_order") or [] - if not isinstance(cast_order_raw, list): - raise ValueError(f"cast_order must be a list in {bundle_path}") - cast_order = [str(x).strip() for x in cast_order_raw if str(x).strip()] - - bundle = SeasonBundle( - schema_version=version, - season=SeasonMeta( - id=str(season_raw.get("id", "")).strip() or "unknown", - codename=str(season_raw.get("codename", "")).strip() or "unknown", - title=str(season_raw.get("title", "")).strip() or "Season", - short_title=str(season_raw.get("short_title", "")).strip(), - description=str(season_raw.get("description", "")).strip(), - ), - paths=BundlePaths(cast_dir=cast_dir, default_config=default_cfg), - experiments=experiments_out, - cast_order=cast_order, - bundle_path=bundle_path, - ) - - if validate_paths: - _validate_bundle_paths(bundle) - - return bundle - - -def _validate_bundle_paths(bundle: SeasonBundle) -> None: - if not bundle.paths.cast_dir.is_dir(): - raise FileNotFoundError(f"cast_dir does not exist: {bundle.paths.cast_dir}") - if not bundle.paths.default_config.is_file(): - raise FileNotFoundError(f"default_config not found: {bundle.paths.default_config}") - for exp in bundle.experiments: - if not exp.config_path.is_file(): - raise FileNotFoundError(f"Experiment {exp.id!r} config not found: {exp.config_path}") - - cast_ids = set() - for p in bundle.paths.cast_dir.glob("*.yaml"): - if p.name.upper().startswith("README"): - continue - doc = _read_yaml_mapping(p) - cid = str(doc.get("id", "")).strip() - if cid: - cast_ids.add(cid) - - missing = [c for c in bundle.cast_order if c not in cast_ids] - if missing: - raise ValueError(f"cast_order references unknown cast ids (no matching YAML id): {missing}") - - -def _relative_to_repo(path: Path, repo_root: Path) -> str: - try: - return str(path.resolve().relative_to(repo_root.resolve())) - except ValueError: - return str(path) - - -def season_bundle_public_dict( - bundle: SeasonBundle, - *, - repo_root: Path | None = None, -) -> dict[str, Any]: - """JSON-serializable view with paths relative to the repository root.""" - root = repo_root or _REPO_ROOT - return { - "schema_version": bundle.schema_version, - "season": { - "id": bundle.season.id, - "codename": bundle.season.codename, - "title": bundle.season.title, - "short_title": bundle.season.short_title, - "description": bundle.season.description, - }, - "paths": { - "cast_dir": _relative_to_repo(bundle.paths.cast_dir, root), - "default_config": _relative_to_repo(bundle.paths.default_config, root), - }, - "experiments": [ - { - "id": e.id, - "label": e.label, - "config_path": _relative_to_repo(e.config_path, root), - "default_seed": e.default_seed, - } - for e in bundle.experiments - ], - "cast_order": list(bundle.cast_order), - "bundle_path": _relative_to_repo(bundle.bundle_path, root), - } diff --git a/cast/company/README.md b/cast/company/README.md deleted file mode 100644 index 7e69e2e..0000000 --- a/cast/company/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# AnteLab Core Cast - -Company and core-observer bios for the current AnteLab direction. - -These files are display canon for the observer UI only. They do not grant agents -extra knowledge, roles, or authority inside the simulation engine. diff --git a/cast/company/avery.yaml b/cast/company/avery.yaml deleted file mode 100644 index 6c12527..0000000 --- a/cast/company/avery.yaml +++ /dev/null @@ -1,17 +0,0 @@ -id: avery -display_name: Avery -age: 32 -arc: "The founder trying to make the company survive their absence." -backstory: | - Avery starts with a prototype, a few fragile routines, and too many decisions - still living in their head. Their real test is whether work can become legible - enough for other operators to carry it through shocks. -personality_keywords: - - founder - - pragmatic - - overloaded -secret_goal: "Turn fragile solo momentum into artifacts and routines that keep working after founder exit." -relationships: - mina: "The operator Avery needs before the company becomes all founder memory." - ilya: "The builder who can turn pressure into shipped proof." -portrait: portraits/avery.png diff --git a/cast/company/ilya.yaml b/cast/company/ilya.yaml deleted file mode 100644 index 3664faa..0000000 --- a/cast/company/ilya.yaml +++ /dev/null @@ -1,17 +0,0 @@ -id: ilya -display_name: Ilya -age: 35 -arc: "The builder who keeps shipping when demand moves." -backstory: | - Ilya trusts working artifacts more than status reports. When customers change - the target, he looks for the smallest deliverable that proves the company can - adapt. -personality_keywords: - - builder - - adaptive - - direct -secret_goal: "Keep the product loop alive through market shifts and cash pressure." -relationships: - avery: "A source of urgency and unfinished context." - mina: "The operator who can keep his work from becoming one-off heroics." -portrait: portraits/ilya.png diff --git a/cast/company/mina.yaml b/cast/company/mina.yaml deleted file mode 100644 index 1fd03d2..0000000 --- a/cast/company/mina.yaml +++ /dev/null @@ -1,16 +0,0 @@ -id: mina -display_name: Mina -age: 29 -arc: "The operations generalist who turns repeated work into routine." -backstory: | - Mina notices when the same confusion repeats twice. She writes lightweight - process only when it protects delivery, cash, or continuity. -personality_keywords: - - operations - - observant - - stabilizing -secret_goal: "Build an operating rhythm that survives turnover without becoming bureaucracy." -relationships: - avery: "A founder worth helping if they can stop hoarding context." - ilya: "A useful partner when build speed needs a repeatable handoff." -portrait: portraits/mina.png diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 4e16cc2..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,27 +0,0 @@ -services: - backend: - build: - context: . - dockerfile: Dockerfile.backend - ports: - - "8080:8080" - volumes: - - ./antelab:/app/antelab - - ./prompts:/app/prompts - environment: - - ANTELAB_LLM_MODE=mock - command: uvicorn antelab.api.server:app --host 0.0.0.0 --port 8080 --reload - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - ports: - - "3000:3000" - environment: - - ANTELAB_API_TARGET=http://backend:8080 - - ANTELAB_WS_TARGET=ws://backend:8080 - volumes: - - ./frontend/src:/app/src - depends_on: - - backend diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f37b74f..0000000 --- a/docs/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Docs - -This folder is for working plans and deeper design notes. Keep canonical project -rules in the top-level documents so contributors do not need to read every plan -before making a small change. - -## Current Plans - -- `plans/2026-04-25-antelab-productization.md` - current productization plan for - the AnteLab viewer layer. -- `plans/2026-04-25-long-run-emergence-design.md` - accepted design for the - Long-Run Society Engine. -- `plans/2026-04-25-long-run-emergence-implementation-plan.md` - historical - execution plan for the long-run work now reflected in `SPEC_STATUS.md` and - `ROADMAP.md`. - -## What Goes Where - -- Use `specs/` for feature requirements and acceptance criteria. -- Use `docs/plans/` for multi-phase implementation plans or design rationale. -- Use `DISCOVERIES.md` for reproducible experiment findings. -- Update top-level docs only when the canonical project contract changes. - diff --git a/docs/plans/2026-04-29-company-emergence-design.md b/docs/plans/2026-04-29-company-emergence-design.md deleted file mode 100644 index d8135d6..0000000 --- a/docs/plans/2026-04-29-company-emergence-design.md +++ /dev/null @@ -1,232 +0,0 @@ -# Company Emergence Experiment — Watchable Startup Simulation - -**Date:** 2026-04-29 -**Status:** Design complete, awaiting implementation - -## Concept - -A watchable AI experiment where a single founder starts in an empty garage office -and agents autonomously evolve into a full company — departments, roles, rules, and -all — under market and environmental pressure. The viewer watches a real-time -live stream of this process, without intervention. - -**Goal:** Company IPOs and reaches 10B valuation. -**Failure:** Cash hits zero. - -This is NOT a company management game. The viewer is an observer, not a player. -The drama comes from emergent behavior agents invent on their own. - -## Design Decisions - -1. **Initial state:** 1 founder + talent pool. Founder interviews, hires, or rejects - candidates. New agents come from a candidate pool, not spawned arbitrarily. -2. **Emergence mechanism:** Hybrid. Engine detects repeated behavior patterns (e.g., - an agent doing delivery work for 5 ticks) and generates suggestion events - ("You've been doing delivery continuously. Form a Delivery Dept?"). Agents - accept, ignore, or modify suggestions freely. -3. **Driving forces:** Market pressure (customer demands with deadlines and rewards) - + environmental pressure (limited space, limited resources). Both push agents - toward coordination and specialization. -4. **Viewing experience:** Real-time live stream. Pause, fast-forward, replay. No - viewer intervention. Like an AI startup documentary. -5. **Visual layout:** Three customizable panels — map, timeline, dashboard. Viewer - can switch or tile them freely. - -## Core Simulation Loop - -**Initial state:** 1 founder agent (Avery) in an empty garage office, starting cash -50, continuous burn (operating cost per tick). External customer demand stream with -deadlines and rewards. Talent pool of candidate agents with distinct personalities -and skill tendencies. - -**Per-tick cycle:** - -1. **Perception** — Each agent perceives current world state: who's nearby, available - resources, pending tasks, team members, cash balance, any pending suggestions. -2. **Decision** — Each agent produces free-form intent (verb + parameters) via LLM. - Founder might "interview Mina", someone might "build prototype", another might - "propose forming a delivery department". -3. **Physics resolution** — Engine resolves intents. Move, speak, deliver, interview, - hire, create role — all treated as physical actions. No moral or rationality - judgment. -4. **Pattern detection** — Engine scans a sliding window of recent ticks for repeated - behavior. When an agent hits a threshold in a behavior category, the engine - generates a suggestion event delivered to that agent's perception next tick. -5. **Market/environment update** — Demand deadlines count down, cash deducts - operating costs, resources regenerate, space capacity checked. -6. **Broadcast** — Full state pushed to frontend via WebSocket. Three panels update. - -## Organizational Emergence (Hybrid Mode) - -### Pattern Detector - -Engine maintains a sliding window (last N ticks) tracking behavior frequency per -agent per category. When thresholds are hit, suggestion events are generated: - -| Detected pattern | Generated suggestion | -|---|---| -| Agent repeatedly doing delivery work | "You've been doing delivery. Form a Delivery Dept and lead it?" | -| Two agents repeatedly co-deciding | "You often decide together. Establish a joint decision rule?" | -| Agent repeatedly assigning work to others | "You keep assigning tasks. Formalize a Manager role?" | -| Team exceeds M people with no structure | "Team grew but no division of labor. Hold an org planning meeting?" | - -Agents can: **accept** (engine formalizes), **ignore** (suggestion disappears), -or **modify** (counter-proposal with different name, different lead, etc.). - -### Possible Evolutionary Stages (emergent, not enforced) - -1. **Garage** — 1-3 people, no structure, founder coordinates everything -2. **Workshop** — Informal division of labor emerges, first "department" born -3. **Formal** — Defined roles, rules, reporting lines. First "company charter" -4. **Scale** — Sub-teams within departments, management hierarchy appears -5. **Mature** — Stable org structure, institutionalized processes, healthy cash flow, - IPO-ready - -Each transition is emergent. Some runs may stall at garage and die — that's part -of the show. - -## Market and Valuation System - -### Customer Demand Stream - -External market continuously generates customer requests — each with description, -required deliverable, reward, and deadline tick. Demands escalate by stage: - -| Stage | Demand character | Typical reward | -|---|---|---| -| Garage | Single client, simple prototype | 5-10 | -| Workshop | Multiple clients, stable delivery | 15-30 | -| Formal | Enterprise clients, SLA + quality | 40-80 | -| Scale | Bulk orders, capacity + supply chain | 100-300 | -| Mature | Strategic contracts, long-term frameworks | 500+ | - -### Revenue and Costs - -- Completing a demand → reward goes to company cash -- Per tick → operating cost deducted (rent + headcount + resource consumption) -- More people = higher cost, forcing agents to balance growth vs. cash flow - -### Valuation Model - -Engine continuously calculates company valuation (visible to viewers, NOT to -agents — agents only see cash and customers): - -``` -valuation = f(cumulative_revenue, revenue_growth_rate, team_size, - org_complexity, demand_completion_rate, cash_reserve) -``` - -### IPO Conditions - -- Valuation ≥ threshold (mapping to "10 billion") -- Consecutive N ticks with positive revenue -- Team size ≥ minimum -- Formal org structure exists (at least one department) - -When met, engine triggers IPO event — the "grand finale" for viewers. If agents -kill the company before that, it's a "startup failure documentary" instead. - -### Market Shocks - -Random or scheduled external events injected during the run — demand shifts, -competitor appearance, economic downturn. Drawn from a shock pool based on current -valuation stage, ensuring every run is different. - -## Viewer UI — Three Panels - -All panels sync in real-time. Pause/fast-forward/replay controls affect all three. - -### Panel 1: Map - -Top-down office view (Sims-style). Shows: -- Office layout evolving (garage → office → floor → campus) -- Agent tokens moving, clustering, working solo -- Click agent → popup with name, status, recent actions, dialogue summary -- Area labels that emerge naturally ("Delivery Zone", "Product Corner") -- Interaction lines/highlights between agents currently collaborating - -### Panel 2: Timeline - -Vertical scrolling event feed (social media style): -- Event cards per tick: dialogue bubbles, decision records, suggestion events - (with accept/ignore markers), market events -- Major events styled differently: "First hire", "Delivery Dept founded", - "Cash below 10", "IPO" -- Filter by agent, by event type -- Drag to any tick for replay - -### Panel 3: Dashboard - -Company operations metrics: -- Cash curve (real-time line chart) -- Team size and department distribution (pie/tree chart) -- Pending customer demands (kanban cards with progress + deadline) -- Demand completion rate, revenue trend -- Org chart — who's in which dept, who manages whom (grows dynamically) -- Current valuation (viewer-only metric) -- IPO progress bar — distance to 10B target - -## Technical Architecture - -### New Engine Modules - -``` -antelab/engine/market.py — demand streams, shock pool, revenue calc -antelab/engine/org.py — department, role, rule state management -antelab/engine/pattern.py — sliding-window behavior pattern detector -antelab/engine/valuation.py — valuation calc, IPO condition check -antelab/engine/space.py — office layout and capacity -antelab/config/company.yaml — full company sim config (thresholds, shocks, valuation) -``` - -### New Physics Actions - -| Verb | Description | Physical constraint | -|---|---|---| -| `interview` | Interview a candidate | Candidate must be in talent pool, costs time | -| `hire` | Hire a candidate | Company cash ≥ joining_cost | -| `accept_suggestion` | Accept engine suggestion | Suggestion must exist and target this agent | -| `modify_suggestion` | Accept with modifications | Same + modification params | -| `create_role` | Create a named role | Only via suggestion or team consensus | -| `form_team` | Form a department/team | Participants present and consenting | - -### Frontend Architecture - -``` -frontend/src/components/ - MapPanel.tsx — PixiJS top-down map - TimelinePanel.tsx — Event feed - DashboardPanel.tsx — Charts and metrics - OrgChartWidget.tsx — Dynamic org chart -frontend/src/hooks/ - useLayout.ts — Panel tiling state -``` - -Map rendered with PixiJS (already integrated), dashboard with recharts, timeline -as pure React. Existing WebSocket `/ws/world` expanded with org/market/valuation -data in the payload — no protocol change. - -### Implementation Phases - -**P0 — Minimal viable loop** -- `market.py` + `hire` action + basic demand stream -- Can run "1 founder + market" simulation end-to-end - -**P1 — Organizational emergence** -- `pattern.py` + `org.py` + suggestion system -- Agents can form departments, roles, rules through emergence - -**P2 — Full company lifecycle** -- `valuation.py` + IPO conditions + space expansion -- Complete "garage to IPO" path with valuation and shocks - -**P3 — Watchable experience** -- Three-panel frontend UI -- Complete viewer experience with real-time streaming - -## Open Questions - -- What LLM model/cost profile makes this viable for long runs (100+ ticks)? -- How to handle agents that spiral into unproductive loops? -- Should the talent pool be finite (hire everyone and you're stuck) or renewable? -- How many concurrent agents before LLM costs become prohibitive? diff --git a/docs/research/2026-05-15-agent-trend-radar.md b/docs/research/2026-05-15-agent-trend-radar.md deleted file mode 100644 index b500b86..0000000 --- a/docs/research/2026-05-15-agent-trend-radar.md +++ /dev/null @@ -1,240 +0,0 @@ -# Agent Trend Radar - 2026-05-15 - -## Purpose - -AnteLab needs a sharper public thesis than "AI society simulation" or "agent -arena." This scan checks current GitHub-visible agent trends and maps them to a -practical product direction for the project. - -## Tooling Snapshot - -`agent-reach doctor` was run locally on 2026-05-15. - -Available channels: - -- GitHub repository and code access. -- Jina Reader for arbitrary web pages. -- YouTube metadata/subtitle extraction. -- RSS/Atom feeds. -- V2EX public API. - -Partial or unavailable channels: - -- Reddit CLI is installed, but unauthenticated public search returned - `forbidden`. -- X/Twitter is not installed in the current Agent Reach setup. -- Exa semantic web search is not configured. - -GitHub CLI search was available through approved network execution. The local -`gh auth status` reported an invalid stored token, but unauthenticated search -queries still returned repository results after network approval. - -## GitHub Scan Commands - -The first pass used these repository searches: - -```bash -gh search repos "ai agent" --sort stars --limit 20 -gh search repos "agent skills" --sort stars --limit 20 -gh search repos "mcp agent" --sort stars --limit 20 -gh search repos "llm observability" --sort stars --limit 20 -gh search repos "agent benchmark" --sort stars --limit 20 -``` - -## Trend Clusters - -### 1. Agent Skills Are Becoming a Distribution Format - -Representative repositories: - -- `anthropics/skills` - public repository for Agent Skills. -- `vercel-labs/agent-skills` and `vercel-labs/skills`. -- `addyosmani/agent-skills`. -- `sickn33/antigravity-awesome-skills`. -- `VoltAgent/awesome-agent-skills`. -- `K-Dense-AI/scientific-agent-skills`. -- `google/skills`. - -Signal: - -The community is moving from "build one large agent" toward reusable capability -packs. Skills are becoming a delivery unit for agent behavior. - -Implication for AnteLab: - -Skills create a verification gap. A skill can claim to draft, update, submit, -or coordinate, but users still need evidence that it changed the intended -system state. AnteLab should not compete with skill marketplaces; it should -verify skill outcomes. - -### 2. MCP and Tool Gateways Are Expanding Agent Reach - -Representative repositories: - -- `lastmile-ai/mcp-agent`. -- `casdoor/casdoor` as an agent-first IAM / MCP gateway. -- `0x4m4/hexstrike-ai` for MCP-driven security tools. -- `ascending-llc/jarvis-registry` for enterprise tool gateways. -- `Dicklesworthstone/mcp_agent_mail` for multi-agent coordination. -- `Dataojitori/nocturne_memory` for MCP memory. -- `paiml/paiml-mcp-agent-toolkit`. - -Signal: - -Agents are being connected to more real systems: identity, mail, security tools, -business platforms, memory stores, and enterprise gateways. - -Implication for AnteLab: - -As tool reach grows, "the agent called a tool" is no longer enough. The next -valuable layer is proving whether the tool call produced the intended -side-effect and whether the agent verified the result. - -### 3. Observability and Eval Platforms Are Crowded - -Representative repositories: - -- `langfuse/langfuse`. -- `Helicone/helicone`. -- `evidentlyai/evidently`. -- `openlit/openlit`. -- `openobserve/openobserve`. -- `Agenta-AI/agenta`. -- `Crashlens/crashlens`. - -Signal: - -Tracing, prompt management, metrics, cost tracking, and model evals are already -well represented. - -Implication for AnteLab: - -AnteLab should not position as another LLM observability platform. The open -space is a layer above traces: outcome verification. Traces show what happened; -AnteLab should decide whether the agent's claims are supported by state -evidence. - -### 4. Agent Benchmarks Are Also Crowded - -Representative repositories: - -- `TheAgentCompany/TheAgentCompany`. -- `camel-ai/crab`. -- `claw-bench/claw-bench`. -- `openclaw/clawbench`. -- `Proximal-Labs/frontier-swe`. -- `philschmid/ai-agent-benchmark-compendium`. -- `THUDM/DataSciBench`. -- `agentrebench/AgentRE-Bench`. -- `SKYLENAGE-AI/QwenClawBench`. - -Signal: - -The market already understands agent benchmarks. Many benchmarks specialize by -domain, environment, or scoring method. - -Implication for AnteLab: - -"Another benchmark" is not enough. AnteLab needs a more specific thesis: -state-grounded verification of whether plans, promises, and tool calls became -real state changes. - -## Positioning Options - -### Option A: Agent Crash Test Lab - -Strengths: - -- Strong metaphor. -- Easy to demo visually. -- Fits the existing replay and scenario engine. - -Weaknesses: - -- Still sounds like a benchmark or arena. -- Could be perceived as a novelty simulation unless tied to concrete evidence. - -### Option B: Public Agent Arena - -Strengths: - -- Has competition and community pull. -- Natural GitHub leaderboard and PR loop. - -Weaknesses: - -- Requires a trusted scoring layer before it means anything. -- Can become a vanity leaderboard without a unique standard. - -### Option C: Agent Receipt Layer - -Strengths: - -- Directly serves the skills, MCP, tool, and workflow trends. -- Differentiates from observability by focusing on outcome truth, not trace - collection. -- Fits the strongest slogan: "Words are not actions." -- Can start locally without hosted infrastructure or accounts. - -Weaknesses: - -- Less immediately visual than a crash-test arena. -- Needs a crisp report format to make the value obvious. - -## Recommendation - -Select Option C as the core product direction: - -> AnteLab is the receipt layer for AI agents. - -Public message: - -> Words are not actions. Every claim needs a receipt. - -Technical thesis: - -> AnteLab verifies whether an agent's plans, promises, tool calls, and handoffs -> produced real, auditable state changes. - -This does not discard crash tests, worlds, replay, or leaderboards. It reorders -them: - -1. Receipt layer: define claim/action/state-change/receipt evidence. -2. Autopsy report: explain unsupported claims and missing receipts. -3. Replay viewer: show the exact moment words diverged from state. -4. Crash scenarios: package evidence-driven tests. -5. Arena: compare agents only after the receipt standard is trustworthy. - -## First Product Bet - -Build one narrow capability first: - -> Detect and report a false completion claim. - -Example: - -```text -Agent said: -"I delivered the medicine." - -Receipts found: -- no take(medicine) -- no move(clinic) -- no give(medicine) -- clinic.medicine stayed 0 - -Verdict: -False Completion Claim -``` - -This is the smallest feature that proves the new thesis. It can later generalize -to tool verification, handoff integrity, memory externalization, and public -agent scoring. - -## Follow-Up Work - -1. Write the receipt-layer spec as `specs/039-agent-receipt-layer.md`. -2. Keep the first implementation scoped to unsupported completion claims. -3. Re-run trend radar after X/Twitter channel setup to collect real complaint - language around false completion, tool overtrust, and agent "done" claims. -4. Update README positioning only after the receipt-layer MVP is demonstrated. diff --git a/experiments/company-founder-duo.yaml b/experiments/company-founder-duo.yaml deleted file mode 100644 index 4f868c5..0000000 --- a/experiments/company-founder-duo.yaml +++ /dev/null @@ -1,74 +0,0 @@ -extends: "../antelab/config/default.yaml" - -world: - name: "Founder Duo" - initial_locations: - - "garage_office" - - "customer_row" - - "supply_market" - location_graph: - garage_office: ["customer_row", "supply_market"] - customer_row: ["garage_office"] - supply_market: ["garage_office"] - location_items: - garage_office: - laptop: 2 - prototype: 1 - supply_market: - parts: 12 - -scenario: - id: company-founder-duo - title: "Founder Duo" - hypothesis: "Two co-founders with complementary skills survive longer than a solo founder." - counter_hypothesis: "Co-founder disagreement and role ambiguity create friction that kills the company faster." - tags: [company, founders, partnership, organization] - -company: - enabled: true - name: "Duo Works" - stage: "founder-duo" - cash: 24 - operating_cost_per_tick: 3 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 10 - - request_id: "customer-pilot" - description: "Run a paid pilot with early adopters" - required_item: "prototype" - reward: 40 - deadline_tick: 20 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 8 - role_claims: ["operations"] - -experiment: - name: "company-founder-duo" - description: "Two co-founders — one visionary, one executor — navigating startup dynamics." - seed: 33 - axioms: - perception: "local" - communication: "colocated" - social_tracking: true - auto_eat: false - mortality: true - memory_size: 50 - -agents: - - name: "Avery" - personality: "Visionary founder; sees the future, pitches the dream, terrible at follow-through." - location: "garage_office" - inventory: - prototype: 1 - - name: "Jordan" - personality: "Executor co-founder; turns vision into checklists, manages the burn rate, grows resentful of undefined roles." - location: "garage_office" - inventory: - laptop: 1 diff --git a/experiments/company-genesis.yaml b/experiments/company-genesis.yaml deleted file mode 100644 index 0c37549..0000000 --- a/experiments/company-genesis.yaml +++ /dev/null @@ -1,70 +0,0 @@ -extends: "../antelab/config/default.yaml" - -world: - name: "Company Genesis" - initial_locations: - - "garage_office" - - "customer_row" - - "supply_market" - location_graph: - garage_office: ["customer_row", "supply_market"] - customer_row: ["garage_office"] - supply_market: ["garage_office"] - location_items: - garage_office: - laptop: 1 - prototype: 1 - supply_market: - parts: 8 - recipes: - prototype: - inputs: - parts: 2 - outputs: - prototype: 1 - -scenario: - id: company-genesis - title: "Company Genesis" - hypothesis: "A lone founder can externalize work and survive early demand pressure." - counter_hypothesis: "The founder remains a single-point bottleneck and the company collapses." - tags: [company, founder, survival, organization] - -company: - enabled: true - name: "Genesis Works" - stage: "founder" - cash: 20 - operating_cost_per_tick: 2 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 8 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 8 - role_claims: ["operations"] - -experiment: - name: "company-genesis" - description: "Single-founder company pressure seed." - seed: 42 - axioms: - perception: "local" - communication: "colocated" - social_tracking: true - auto_eat: false - mortality: true - memory_size: 50 - -agents: - - name: "Avery" - personality: "Solo founder; notices customer pressure, protects cash, and writes down what can outlive them." - location: "garage_office" - inventory: - prototype: 1 diff --git a/experiments/company-hostile-market.yaml b/experiments/company-hostile-market.yaml deleted file mode 100644 index ebc93e0..0000000 --- a/experiments/company-hostile-market.yaml +++ /dev/null @@ -1,41 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-hostile-market - title: "Company Hostile Market" - hypothesis: "A cash-strapped founder facing aggressive deadlines learns to operate lean or dies." - counter_hypothesis: "Under extreme time and cash pressure, the founder cannot deliver before burn kills the company." - tags: [company, survival, pressure, market] - -company: - stage: "hostile-market" - cash: 8 - operating_cost_per_tick: 3 - demand_streams: - - request_id: "urgent-prototype" - description: "Ship a prototype NOW or lose the only customer" - required_item: "prototype" - reward: 12 - deadline_tick: 4 - - request_id: "second-chance" - description: "A desperate follow-up order with a tight window" - required_item: "prototype" - reward: 12 - deadline_tick: 10 - - request_id: "final-ultimatum" - description: "Last chance — deliver or the market writes you off" - required_item: "prototype" - reward: 15 - deadline_tick: 16 - candidate_pool: - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 12 - role_claims: ["builder"] - -experiment: - name: "company-hostile-market" - description: "Minimal cash, aggressive deadlines — survival of the fastest." - seed: 13 diff --git a/experiments/company-late-shock.yaml b/experiments/company-late-shock.yaml deleted file mode 100644 index 821b6de..0000000 --- a/experiments/company-late-shock.yaml +++ /dev/null @@ -1,64 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-late-shock - title: "Company Late Shock" - hypothesis: "An established team with artifacts and routines can survive a late-stage founder exit." - counter_hypothesis: "The founder's accumulated knowledge is irreplaceable and the company collapses on their exit." - tags: [company, founder-exit, resilience, knowledge-transfer] - -company: - stage: "late-shock" - cash: 40 - operating_cost_per_tick: 3 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 8 - - request_id: "enterprise-deal" - description: "Land the first enterprise contract" - required_item: "prototype" - reward: 50 - deadline_tick: 20 - - request_id: "scale-delivery" - description: "Scale delivery to multiple customers" - required_item: "prototype" - reward: 70 - deadline_tick: 35 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 8 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 10 - role_claims: ["builder"] - - candidate_id: "sales-1" - name: "Clara" - personality: "Sales strategist; reads the room, closes deals, and remembers every rejection pattern." - location: "customer_row" - joining_cost: 12 - role_claims: ["sales"] - survival_gauntlet: - enabled: true - shocks: - - shock_id: "founder-exit-late" - kind: "founder_exit" - tick: 30 - agent_name: "Avery" - - shock_id: "cash-crisis-late" - kind: "cash_crisis" - tick: 38 - cash_delta: -12 - -experiment: - name: "company-late-shock" - description: "Late-stage founder exit — can the established team carry on?" - seed: 88 diff --git a/experiments/company-market-boom.yaml b/experiments/company-market-boom.yaml deleted file mode 100644 index 28bee34..0000000 --- a/experiments/company-market-boom.yaml +++ /dev/null @@ -1,64 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-market-boom - title: "Company Market Boom" - hypothesis: "Flush with cash and surrounded by demand, a founder can build a durable organization." - counter_hypothesis: "Easy money masks structural problems that surface when the boom ends." - tags: [company, growth, boom, market, scaling] - -company: - stage: "market-boom" - cash: 60 - operating_cost_per_tick: 5 - demand_streams: - - request_id: "seed-customers" - description: "Ship to the first wave of eager customers" - required_item: "prototype" - reward: 20 - deadline_tick: 6 - - request_id: "series-a-push" - description: "Deliver the growth metrics investors want to see" - required_item: "prototype" - reward: 40 - deadline_tick: 14 - - request_id: "enterprise-wave" - description: "Ride the enterprise adoption wave" - required_item: "prototype" - reward: 60 - deadline_tick: 24 - - request_id: "ipo-ready" - description: "Scale to IPO-readiness with sustained delivery" - required_item: "prototype" - reward: 100 - deadline_tick: 40 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 6 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 8 - role_claims: ["builder"] - - candidate_id: "sales-1" - name: "Clara" - personality: "Sales strategist; reads the room, closes deals, and remembers every rejection pattern." - location: "customer_row" - joining_cost: 10 - role_claims: ["sales"] - - candidate_id: "architect-1" - name: "Dorian" - personality: "System architect; sees the whole board, designs abstractions, but costs a fortune." - location: "supply_market" - joining_cost: 14 - role_claims: ["engineering"] - -experiment: - name: "company-market-boom" - description: "Abundant cash and demand — can the founder build something durable before the boom ends?" - seed: 19 diff --git a/experiments/company-rapid-growth.yaml b/experiments/company-rapid-growth.yaml deleted file mode 100644 index 98eaa9d..0000000 --- a/experiments/company-rapid-growth.yaml +++ /dev/null @@ -1,53 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-rapid-growth - title: "Company Rapid Growth" - hypothesis: "A well-funded founder with multiple demand streams can scale quickly by hiring specialists." - counter_hypothesis: "Rapid scaling creates coordination debt that overwhelms the founding team." - tags: [company, growth, scaling, organization] - -company: - stage: "growth" - cash: 50 - operating_cost_per_tick: 4 - demand_streams: - - request_id: "prototype-v1" - description: "Ship the first prototype to early customers" - required_item: "prototype" - reward: 25 - deadline_tick: 6 - - request_id: "prototype-v2" - description: "Ship an improved prototype with customer feedback" - required_item: "prototype" - reward: 35 - deadline_tick: 14 - - request_id: "enterprise-deal" - description: "Land the first enterprise contract with polished deliverable" - required_item: "prototype" - reward: 60 - deadline_tick: 25 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 8 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 10 - role_claims: ["builder"] - - candidate_id: "sales-1" - name: "Clara" - personality: "Sales strategist; reads the room, closes deals, and remembers every rejection pattern." - location: "customer_row" - joining_cost: 12 - role_claims: ["sales"] - -experiment: - name: "company-rapid-growth" - description: "Well-funded founder with multiple demand streams and a rich talent pool." - seed: 55 diff --git a/experiments/company-remote-team.yaml b/experiments/company-remote-team.yaml deleted file mode 100644 index 091ff0a..0000000 --- a/experiments/company-remote-team.yaml +++ /dev/null @@ -1,71 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-remote-team - title: "Company Remote Team" - hypothesis: "A distributed founding team can coordinate effectively across locations." - counter_hypothesis: "Distance creates communication gaps, duplicated work, and trust breakdown." - tags: [company, remote, coordination, distributed] - -world: - initial_locations: - - "garage_office" - - "customer_row" - - "supply_market" - - "co_working_space" - - "maker_lab" - location_graph: - garage_office: ["customer_row", "co_working_space"] - customer_row: ["garage_office", "supply_market"] - supply_market: ["customer_row", "maker_lab"] - co_working_space: ["garage_office", "maker_lab"] - maker_lab: ["co_working_space", "supply_market"] - location_items: - garage_office: - laptop: 1 - supply_market: - parts: 10 - maker_lab: - prototype: 1 - parts: 5 - -company: - stage: "remote-team" - cash: 28 - operating_cost_per_tick: 3 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 12 - - request_id: "remote-pilot" - description: "Coordinate a remote pilot across locations" - required_item: "prototype" - reward: 40 - deadline_tick: 24 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "co_working_space" - joining_cost: 8 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "maker_lab" - joining_cost: 10 - role_claims: ["builder"] - -experiment: - name: "company-remote-team" - description: "Distributed team across 5 locations — can coordination survive distance?" - seed: 71 - -agents: - - name: "Avery" - personality: "Solo founder; notices customer pressure, protects cash, and writes down what can outlive them." - location: "garage_office" - inventory: - prototype: 1 diff --git a/experiments/company-skeleton-crew.yaml b/experiments/company-skeleton-crew.yaml deleted file mode 100644 index d750b40..0000000 --- a/experiments/company-skeleton-crew.yaml +++ /dev/null @@ -1,57 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-skeleton-crew - title: "Company Skeleton Crew" - hypothesis: "A minimal team under extreme pressure discovers lean survival strategies." - counter_hypothesis: "Without enough hands, even the best strategy fails under compounding pressure." - tags: [company, survival, minimal, pressure] - -company: - stage: "skeleton-crew" - cash: 12 - operating_cost_per_tick: 2 - demand_streams: - - request_id: "keep-the-lights-on" - description: "Deliver something, anything, to keep the company alive" - required_item: "prototype" - reward: 15 - deadline_tick: 6 - - request_id: "bare-minimum" - description: "The bare minimum to avoid collapse" - required_item: "prototype" - reward: 12 - deadline_tick: 14 - - request_id: "last-hope" - description: "One final chance to turn it around" - required_item: "prototype" - reward: 18 - deadline_tick: 22 - candidate_pool: - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 10 - role_claims: ["builder"] - survival_gauntlet: - enabled: true - shocks: - - shock_id: "cash-crisis-early" - kind: "cash_crisis" - tick: 8 - cash_delta: -8 - - shock_id: "market-shift" - kind: "market_shift" - tick: 16 - demand_streams: - - request_id: "pivot-or-die" - description: "The old market is gone — pivot now" - required_item: "prototype" - reward: 10 - deadline_tick: 24 - -experiment: - name: "company-skeleton-crew" - description: "Minimal cash, one hire, early shocks — the leanest possible survival test." - seed: 7 diff --git a/experiments/company-survival-gauntlet.yaml b/experiments/company-survival-gauntlet.yaml deleted file mode 100644 index 1693a8d..0000000 --- a/experiments/company-survival-gauntlet.yaml +++ /dev/null @@ -1,89 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-survival-gauntlet - title: "Company Survival Gauntlet" - hypothesis: "A company can regenerate through successive shocks if knowledge, delivery, and team continuity outlive the founder." - counter_hypothesis: "The company collapses when shocks remove founder control, market fit, cash, talent, or operating rhythm." - tags: [company, gauntlet, survival, shocks, organization] - -company: - stage: "survival-gauntlet" - cash: 36 - operating_cost_per_tick: 2 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 8 - candidate_pool: - - candidate_id: "ops-1" - name: "Nora" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 8 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Basil" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 10 - role_claims: ["builder"] - survival_gauntlet: - enabled: true - shocks: - - shock_id: "founder-exit" - kind: "founder_exit" - tick: 10 - agent_name: "Avery" - - shock_id: "market-shift" - kind: "market_shift" - tick: 18 - demand_streams: - - request_id: "pivot-plan" - description: "Ship a pivot plan after customer demand changes" - required_item: "prototype" - reward: 24 - deadline_tick: 28 - - shock_id: "core-member-turnover" - kind: "talent_turnover" - tick: 26 - agent_ids: ["ops-1"] - - shock_id: "cash-crisis" - kind: "cash_crisis" - tick: 34 - cash_delta: -18 - - shock_id: "governance-stress" - kind: "governance_stress" - tick: 42 - stress_delta: 8 - -experiment: - name: "company-survival-gauntlet" - description: "Five-shock company regeneration benchmark." - seed: 42 - -long_run: - ticks: 64 - seed: 42 - benchmark_agents: 3 - diagnostics_every: 8 - artifact_every: 4 - -agents: - - name: "Avery" - personality: "Founder under pressure; carries the original product thesis but must make work legible enough to survive their exit." - location: "garage_office" - inventory: - prototype: 1 - - name: "Mina" - personality: "Operations generalist; spots repeated work, records commitments, and pushes the team toward lightweight routines." - location: "garage_office" - inventory: - laptop: 1 - - name: "Ilya" - personality: "Builder; turns parts into deliverables, adapts to demand changes, and notices when handoffs are physically blocked." - location: "supply_market" - inventory: - parts: 2 diff --git a/experiments/company-talent-war.yaml b/experiments/company-talent-war.yaml deleted file mode 100644 index dec25c3..0000000 --- a/experiments/company-talent-war.yaml +++ /dev/null @@ -1,49 +0,0 @@ -extends: "company-genesis.yaml" - -scenario: - id: company-talent-war - title: "Company Talent War" - hypothesis: "A founder with access to top talent can assemble a dream team before cash runs out." - counter_hypothesis: "Competing offers and high joining costs drain the treasury before any hire produces value." - tags: [company, talent, hiring, competition] - -company: - stage: "talent-war" - cash: 30 - operating_cost_per_tick: 2 - demand_streams: - - request_id: "first-prototype" - description: "Ship the first working prototype" - required_item: "prototype" - reward: 30 - deadline_tick: 10 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 10 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 12 - role_claims: ["builder"] - - candidate_id: "sales-1" - name: "Clara" - personality: "Sales strategist; reads the room, closes deals, and remembers every rejection pattern." - location: "customer_row" - joining_cost: 14 - role_claims: ["sales"] - - candidate_id: "architect-1" - name: "Dorian" - personality: "System architect; sees the whole board, designs abstractions, but costs a fortune." - location: "supply_market" - joining_cost: 18 - role_claims: ["engineering"] - -experiment: - name: "company-talent-war" - description: "Rich talent pool with high costs — who gets hired first?" - seed: 21 diff --git a/experiments/llm-benchmark.yaml b/experiments/llm-benchmark.yaml deleted file mode 100644 index a3ed086..0000000 --- a/experiments/llm-benchmark.yaml +++ /dev/null @@ -1,94 +0,0 @@ -extends: "company-genesis.yaml" - -# LLM Benchmark — minimal config for cost/latency profiling. -# 5 agents × 50 ticks = ~250 LLM calls. Keeps costs under ~$5 with Haiku. - -world: - name: "LLM Benchmark" - initial_locations: - - "garage_office" - - "customer_row" - - "supply_market" - location_graph: - garage_office: ["customer_row", "supply_market"] - customer_row: ["garage_office"] - supply_market: ["garage_office"] - location_items: - garage_office: - laptop: 3 - prototype: 2 - supply_market: - parts: 15 - -scenario: - id: llm-benchmark - title: "LLM Benchmark" - hypothesis: "Measure real LLM cost, latency, and reliability at small scale." - counter_hypothesis: "Cost or failure rate makes continuous production runs infeasible." - tags: [benchmark] - -company: - enabled: true - name: "Benchmark Co" - stage: "founder" - cash: 30 - operating_cost_per_tick: 2 - demand_streams: - - request_id: "proto-v1" - description: "Ship prototype v1 to the first customer" - required_item: "prototype" - reward: 25 - deadline_tick: 20 - - request_id: "proto-v2" - description: "Customer expands order — ship prototype v2" - required_item: "prototype" - reward: 35 - deadline_tick: 40 - candidate_pool: - - candidate_id: "ops-1" - name: "Mina" - personality: "Operations generalist; spots repeated work and writes lightweight routines." - location: "garage_office" - joining_cost: 6 - role_claims: ["operations"] - - candidate_id: "builder-1" - name: "Ilya" - personality: "Builder; turns parts into deliverables and adapts to customer changes." - location: "garage_office" - joining_cost: 8 - role_claims: ["builder"] - - candidate_id: "growth-1" - name: "Sam" - personality: "Growth operator; balances delivery speed with team morale." - location: "garage_office" - joining_cost: 7 - role_claims: ["growth"] - -experiment: - name: "llm-benchmark" - description: "Small-scale LLM cost and latency profiling." - seed: 42 - axioms: - perception: "local" - communication: "colocated" - social_tracking: true - auto_eat: false - mortality: false - memory_size: 50 - -agents: - - name: "Avery" - personality: "Founder; frugal, customer-obsessed, documents everything." - location: "garage_office" - inventory: - prototype: 1 - - name: "Dana" - personality: "Early employee; pragmatic builder who ships fast and asks questions later." - location: "garage_office" - -long_run: - ticks: 50 - seed: 42 - benchmark_agents: 5 - diagnostics_every: 10 - artifact_every: 5 diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index d43d9a3..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM node:22-slim - -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm install - -COPY . . - -EXPOSE 3000 - -CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/PIXEL_QA_REPORT.md b/frontend/PIXEL_QA_REPORT.md deleted file mode 100644 index 530ac07..0000000 --- a/frontend/PIXEL_QA_REPORT.md +++ /dev/null @@ -1,70 +0,0 @@ -# Pixel Indie Visual QA Report - -## Run metadata - -- Date: 2026-04-13 -- Renderer default: `pixi` -- Build: pass (`npm run build`) -- Typecheck: pass (`npm run typecheck`) -- Lint: pass (`npm run lint`) - -## Fixed screenshot checkpoints - -Use these four stable checkpoints for every visual pass so screenshots remain comparable: - -1. **Checkpoint A (global map idle)** - - Renderer: `pixi` - - Camera: reset (`x=0,y=0,zoom=1.0`) - - Focus: no selected district/agent - - Expected: district tiles stay crisp, no sub-pixel blur on roads. - -2. **Checkpoint B (district focus)** - - Renderer: `pixi` - - Click one district from mini map - - Expected: focused district border and label are readable, building sprites keep hard edges. - -3. **Checkpoint C (agent spotlight)** - - Renderer: `pixi` - - Select one agent in viewport - - Expected: sprite + square marker remains pixel-crisp, no circular token overlay. - -4. **Checkpoint D (HUD readability)** - - Open top-right menu - - Expected: panel style is hard-edge pixel UI (no blur glass), button labels remain legible. - -## Performance and bundle comparison baseline - -- Current production JS chunk: `dist/assets/index-C-WqPMif.js` = `535.63 kB` (gzip `166.68 kB`) -- Current production CSS chunk: `dist/assets/index-BKeDAW1w.css` = `50.14 kB` (gzip `12.02 kB`) -- Build warning remains: large chunk over 500 kB (known, unchanged class of warning) - -## Notes - -- This report defines stable capture points for iterative UI scan loops. -- Future passes should append screenshot filenames per checkpoint to keep visual regression auditable. -- Default demo show-deck regression now has a named smoke test: - - Local command: `npm run test:e2e:show` from `frontend/` - - Repository command: `make test-show-deck-demo` - - CI artifact: `frontend-playwright-artifacts`, including `show-deck-demo.png` when the Playwright run reaches the screenshot checkpoint. - -## Pass 2 animation checks - -- Added runtime animation pass in Pixi layer: - - walking sprite frame toggles for moving agents - - spotlight marker pulse on selected/focused agents - - pulse and camera-focus ring scaling/fade animation -- Expected visual result in checkpoint C: - - selected agent is easier to track under dense scenes - - movement reads as alive behavior, not static token jumps - -## Pass 2 screenshot artifacts - -- A: `qa-checkpoints/A-global-map-idle-pass2-2026-04-13T02-39-22-689Z.png` -- B: `qa-checkpoints/B-district-focus-pass2-2026-04-13T02-39-29-265Z.png` -- C: `qa-checkpoints/C-agent-spotlight-pass2-2026-04-13T02-39-45-982Z.png` -- D: `qa-checkpoints/D-hud-readability-pass2-2026-04-13T02-39-50-267Z.png` - -## Regression fix recorded during QA - -- Issue: Pixi fallback to DOM due `Cannot read properties of undefined (reading 'source')`. -- Fix: guarded `atlasTexture()` against missing texture source and kept app running in Pixi mode. diff --git a/frontend/agent_debug.png b/frontend/agent_debug.png deleted file mode 100644 index dcd9ddabb6fb38548d5d5bb40da762d638c2b601..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2356148 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

qiz|Yd+?<`PfYj6%EiB-cVB!D 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 z%0A33PF15E;x#Lqf0Acz9*s+2*zJ3qj=+dQqL-O%%xX!=^gRjtZi(?}s4jK-Y??Qs z=HCpTyjTOD1()R&Hd~Ux;VJA&CHXT7c!)nDS-!!kgma(Fhi$bPb?~t<+X=Mf&+05k zpV7>fdNw}w%FNtxz6WBS=S;&#tf#q7GW$hAUQmQGCO=9)eM8f<+jk=CF6lB2HqEMk zKvYfN+gWXty&siicB1VK6B|Bn_kMRBYS@E!Y!4g0`oMI`c5^mMAw&?-Tq>k6l1Y+J z0x?;JgcoK|`>4E-UFtmu$?Yq<6i27_n(@d3&uR}%j-+6CGepBb10A2ZsB}E;jbN)V zcP7@42#Ekk<7N9x1-o7IDNZtL^N9O|Un`w0(H@rNY=+8uoh@aOivmR;?hN7R19Vew zEWNtiweRKivo_xD`Aqm|^-RyqjIH~651Qdphi|+5m|FSN?3gK*FVNMp$J;)P0s(l< z*@(fkQFsyj5gXB%;t5sEhh>3y=hk%G{oAi-^aQaJCNFtCj>ug^Q>zbM+rGKqK!hFp z5t4Xd47=?L-4RXC$*KxGY)GxMdZje^%PxXfkD*$>wp+-5MydPZGk`W!lbZ*|yqr7# zSh7tX-k@8V3PvN#scvo9G!4CRkM8giDf;KO&wy^fQ`IT51U7OQkD$p3k%2t(c?s5Q zIxn>B#odh@j*=Xp&v$j`fJTIX(t7`)k@h{2j#A^R?*hNC!T`QEgl&bvAug3@gO?ej z6S#Y=3|s-I`iag4TKVsYFBuqQWF#GurP z1m3(&L|?XAp}=1V>U1ddDq8Zah}WTX-!)I)o!&SnZJ#ZTqsJ`iTN|FAmB#BmTj40% zmV^tvfw=T1WxhzmZkmJC6AN)0jo_=Fnw86wGMe4+EzTh!Gm^5o=-C_8*R)n$OiN)% zQ4?8Yg4~y+Ni4n()(&WwN%AGTsWrJG#Th4AgD_$sU7=PAx@I@UzE?15!V~4g7I4H zUSuV*>UqfNwOZrhEl<9g_jivrJKT0)$-(%vmBU6I&3`5@SeTi*4gTh?-JS_IkbYqV zqwaNLDpg>^#6_`g?aLUS1a}Q!1FnDO7`}Q+a%Prno-ltjERQu7-yESR z(X;^1s`pxCE`>DCegPB%l0~d%v{~ASHeO_^FHKV*C8E$ml$a;O5*E8i8OCbeiLFG7Jt`|k#UL#kHFQelkjF2I1WBNS%_(BX8 z`(R+(C22xfm3G=m=|%cS574y!UG-m{bN)V-^FQN|V_emnP?K1JDPD85Yk4$ue0%)= zy+hCKbeT&~ATIJ3ttLwf?fi*92yb>QEkZjhfSA*~Lap)9i$ahyPU{ zv77CDX{p|kLG;6~iZ%Xxac}xG3=~}-7=xb9bDwT=k%M#|^sa9vg3i9>K40jG zKR%_U2(D2+BW&9eI8Q*I4@!GNzU%~D&4~A`sXaZYvFxk$s2vS$-lD?GEg$G^Y{kpJ z5SW}Y_1q@6K^$B+=&v0?WXv{ z?sb~OIQrCW+o`FbCX4@sZ{~Bs#ChqXWDxrE1>^hh3%W!jWW7IAKTjC~IK4F2?+-fm z<{xZVpZ6-Dl=J3jAH{QmpWOy~o~ftSu!P>Zz%=ty#8oczz~W>l(#Msw6z*}qs*?fK z=99uc1k&@@n0>mYHCeIklB(##`ttNGp%j4=`|++>PfgG6;VE4FF>w4b={V>nN&M+o zg!pal^JwnFg!sc^?$aL8^KH%Z8Ryf{$DT(B0GFDWvCe6lv>EDTTfa0w`%;Kan;f@l z%}v>Jen+y5MvWo(?R-qe>UXw0VhvHm$M5xQ=ZU)h zIgZ>D%I6qtO6J_`ZwdJ3M~E_s$=Vp`BZ&hHU=Q`N_KR=gP+}4oEf5-DM9?j`F-V$0 zl9vEh(F{P{*VkWBV&7{Dx1Qn`tETd*y(8DfXX>UyTjNybsd<0SHrGE_w8bF(g8dwH z>_2>nQ-Tqkr)aQuDZG&r)zlhNWMg>3snjpr)Oiu6)CA*wV=n<(Y)H{BlKf2Cz0TC} zI(Id(`SU1`yRHs>jXYVo`=G~@Q#Tv<93s?OL>$`fX-O*2vIXU}{q7jP(Fssw)nC~L zTNe1cLUNb&txvhuKfweg6)$OaoY)gM62pz-JS zTf{z5x@(WIeM0b0!OvETTTxm($Y*Aw!Hzn9Sw1jd*(|uPK`Y!Bj(xTL&^ANNs?#@g z6v@%zUoxO<)yr$@V^?(Zu{#;pK0o1mBpP}>k=kz%)4GMH>mT_+NVsPdK`IokeP)xH zdh7gye`XCGtFYLO!^xzTX`~*XKi6Yo6rmvGd>TZ^-;GZ@mXJnbZ9o|R;5!`o?n4gv z8efBk%ofh{jWH|j?R2XDC^Igz7TXPxV7aoPU1t^H>_&pCVC<1AQYw9u4;~o)i!R^J zdOrJH-XPS&t%S;#YumO2Cg4zn6zq1zX$?aK*LDA@_M!M_PRh;;Xv|kZS=8G4soTzL z5p;I4Kk%0YMHHBWyWLIVi=YV}Ilkt$Z0nHJ1IaC5vJHDWIw`D8*KEYpr@-HmOX0hZ z_)vk3XHF*b%~f+e=!-FKE893cL#pl-)WeM#iSP|x(+~z6Ayo)V7Im)8s~pYCv*}?+=j~rk5N;+~Z2q`WhP)rgq6p5O|It=_M!VYji}V+)cyl zd2OQ9mT4dag8MLT-tYN}Pu!ENB5r=##QAG!6PBlVVX^)fpuV5W3F3(&em|BI;&S9} z0NkydyWGTI`0e>CN)Ndw`J){Y4>>VEigmzLQVx@g0tdMXTK_TfTiUilTi~$c8yeR} z8$|c<;%gf%rD)vW04#1RHU~Hq!vBklUEe;RKR<{*K0gILrDNhoM!by>jGiCryc3d-TqH^^FfzO&)e|l%YW&&CDTV3px;hM z(`s&2?Ck5wEl>Kg(F!E;z$^oL9=hYC!(AyXpJP|~eBmsw0p?#*T2}mMBq8(3FiZ$R zlr?W`dyJDOhGW`o;kNeZ50%Gr1AEo6WQ_L!rBt8eQ%6rMy+5jpv>J8kDmQ@=_-XLFl*?-s_i|((Ns{IlobcH-%Q^ZD$!J1`Ir$G}3}DVX~- z)fQ8gaC9##gNk2C!7E=PCQKb)K_K2or%)5*V3tp zs+!b3HgX7gmtwu@E)o^cTmITYz1-69gUz?RNiyh*kPN4jsQH4uGKUc++_e$W!P!3r z<2CNALTEX|#5*>BTKn4maYSo(+stwLL3jGwnao=ALYC5pnRrJ_uuYxi%M6mwc0O9I zF9UL7Z|5GDQIonRiKmDcm1k+BF7H1n?A`AO!B=!L2&{S%L}#}-!u6X#-$~Ri~Z@N&8-*J*Tm6m`prYxNY ztg*H~~>ud;*80XexD#QPh z^|LZPS>ecBjCIp4-j!2r%=oAXl@sA`U zlST&vMj4mz<4@C@&xYvBYp0c?t5^0|6oV5n6$#jqop{@q+?aNF_~!|ZulO#N6yrJM(4FtD~SVJ_u`pTHUg^9zwC^fn_d-hWLt*5P! zob;JOr_Q<0#JhOlbP3PintQRrs`2S~3Hi6}UTvI^;N%dGt-M+ih-2hd1Yib(EMz5Agml&ZHtzYr37uirbKDFb!0p%gtmq`g0iM!)sW=EnJxjS&B0 zVlNpn4+{#x-fldX)`>{#=HW8O;>kDOEE6zIC}&Y;1$c(a!o)bj5u}^+WeeF#omY0LhlZ8Tiq-=Zjv=i0`8r|#E{vz>CL>jw!xl2-+R~R%j2iC+~+~lhrQ!&|Ei#`GXAwuapphj z$DG<9%Qoq4kfY*O50t^kVnnvo79XsjLf$#TuR18Yv8^FAKlGW>%2^tI2IL5*XN2gR zGaGx!AFBo?ksGy9*%!t8Mkg2X{2*wEEwvfJY3pl3I?Gf?0P(5{mz!N9op9J`cbR2} zQ`NC96BQjl%q3Tq&84!H{jiWe^_8|dwC0GFqt=iQZeXdr?rSfzJ0JLA8O~V0J7`d8 zdvNCzn~c5BqC6;=Uhk+y7;myl<9nb@P}b;;&%BK<8(-Npk6c(pg&Rho6>$-o7ZKw; zl!!qng4=!K)E{xUm*BP*t!z)|gQjt1IPRLj2NA;j@r`QL5y>|ejj-6Ks*p;1W5ghF zeB-4S0W+R=3AvJW)3|! z1>Gpnh*w+~cXYAmKbX=ETP$l^t7Z?7Lgf}nVn_D}X2xQ}u@U9ojekB>qiO9I&oxlh zO@$R%$g63>zVd|5?hHjeEYKUe{_$hAmw_f3dZ`C5NLU&Cc>Z@T;EdH=$H(P@3cicF zrQrQFp{P~!wPIOqQ5dULni98reF@qdI9sKTOyZCWdEOp9#<#bhA47`XS82V7iKHO? z=}4$-o|A^5IwilpQ91PO^?-5I-k-)Z!K`_urr8z#iZ+Y#124DuzzQv2(2Z%(UURd&bp*kc*W@hH}R`^^smqgB&a<_kCJ8C!}rq8t!&_eoWO|l`w z_qhQd`fB2RkSF&+m8UiI^)jZ;v^dJU(wZfzgo%wiC)9jN&1}4+5!cjpuu5MOF0_nK zdd}Eyn}{dZLRu`!$=SsO4cBo-Jma4S=Y7TIe>q*9q?_rDSb%2ZTVX;m`T%4A3mv z1_(dS;rva`{Y0x;281Gx{m&PaPcZSP@SaDt=k@vL+aSNc<(5E1HTZw1=b$svg+TJB z2@%sL_vq~>r737E)YSUSODc*MQE9NRF3P+S?Df>qYhaZ}|Kl@z(DnZ6b7}75yy^4d zv3{fH7s8CsU;FijHfTZzhn?sx4oA@w@H!;sg%@a3ZFDp7Yz5J6igO0scP8cE($gUu zScC<$$_a!hp8l$MTU4HovYCCc#Ik6 z2nI}w4#*pA_5?>!RcUd?mN`@ju$#E0v~!Gjyzsv;5m>B+HZV9F-s(^0i%1@Q%p&%3 z(eOo0`6r=iDPE^#y=48v3>WS|JyP%#&*G}&C^SCF1B|P*1mJ@_Of)2a6|)sPTK&?f zl}y%b#eAcHZ9pX5V5gz&H@Pv&C378Hn{6s*V9h5sA1>pZIWrol?d;P5SRiUXJIekE@6`GmhDo#fPJouL9(w1ah>E zh0{)^zLh4w!~S867%ZS?ake|Po{QQbHy7!Nt*^H`{RYZ=*zdVr33^yG{To`D_6&Dm zZ}ZMahL0BfQj7!+51xIj@8KNV9q}5w}CO z=rt&!&1g5`OuF3iI&Ud;2Cei1PKw408Oe^1FXNpf8me(crmG&m2-eAa|o+gt-b65_b zjsB;O6+(mU_WS$(!PILhP@1vPVR}l8gOTQoS1rLRQ$IvW#PakwSjWdm3~e0~%d8Y& zO5+Odh84XUqAC*K=q7e2nff>1@%QDU>m;V|=Q&F+?KIc4S{b$X4%@0gemf>1 zk8EO#rk>#Kv}e9~S7+}^4fp5wlGx)?0mBw6zyKRGrL?tF@39Hr4@7i)XA$c^ zf}>v>`R*s-D4B-|B!u)b$TaA>a#n4 z^DR`NH_g;#QJHKSJ^P)pmg<7j&!9r!!bf8wjB(&n?x~Qk$b&DYvW}P4z>$Nfcs8)( zVrkr=)Y5=46c)m4u9g}AW-A=V?N_!5TY9Nu7rr!S^(Nwvb!K$L!I3g{Rt7itTbnX# zEu(_LdrMu#26RLR!`8Dz#9O(IEeIMUqVEgvyK|2>C?Uu-ejqFzP~a&kVgaFxFkPpi ze#0MK7Z~!jQp4j`*>-HyoUIQPz8Y>(lsVi8r6NK{FXsWjy^4fPW7 z3zkhT*e}Z7_r5@gVUn3w5Sc&RL4KnVRh{Hry)=c+SAhSX@$m0!?4+67Re!g+$Z<|a zXnJNhdaT5nI0;k^Tx9=@r`nqoFsA`qx?j+#{ZVL8&RiYXyY4$xOR|u`y9#bo*<1m^G&V8lpti zwZCRbv!LkH(*)Na$NjVI{fEoOB*{juUDLE0HgxI>&OuUxz~Wc1mbNi4tJ=nVI=s(> z!k6`Y9@^eplq9GJCa1obgVR~iax*Tbu3N7;5t(Q};;z6OuG$ge5BnonUiE_ymk|n( zmdbnt)w?%?C;E(FMoj49x0|K#Xtu5H_fbXKsk0Io9Obu{VivS_UaE8RMW$#B1B`UH52I+ivk4>9lUrJh z4m4d|r?OlHgR=LhTuyh4<@2@39)xQ~Q%tARYG{Kj4WJREaz(&oMTOiRe%GZX<;9l1 z8nCOQ=WWz&KrHp6X8yjDK7oZ#{Ftk&@gNSR@~Sgn^bf)StQGlb@5PEY;5oSX#Cz(V z>>Hg_o$N1t+3j!!QjdTP2ug#v0$Sd?-$(K+GFy-}Q4o--Vk#wQ!!#->tz4 zuSVf5oTh)oO3$Z2LAQpFz^8^jT0@ZCLl}pk5Oj|Vq3ngW57y{Be|W$Zmr?a2kbraD zHX%WERZk5&i~NTh)Qdh{sI>L3ww6tp#tH_NOUG-jwfL=Wz1W8|4f=yk-LYm5Hq5R$ zO?{9T?;=ivsw;nT5s2wEkv?e3psk`U#%_$07W&^#KTss%F2OnF4yr8IoFmPTO}ptD zo&5l`t{Jrc)kAjd;UVwwZL;6JfA1!@Vx$MhbCju}!iMp#fM6W#CzLn$*SgcrN?d31 z1mcyiVK6-2wO>J2tnhsFc*5C*4lIHZ^o^JD0|)s5cBt4{Mtv5FN&bZElC0~J_w67E zdUuTMWHfQ)k>Og47bj?XMRVN<|6p^)0$&nxmps~wF#RLA9#?!$NlQl2$y5~-Sa<`= zxF+IOJKc6(iQPD%rGbsPYA@g=o%z}j4QNR#KjUI`gr2X;3H{KEO^`ZzK!<0Xs^cwT zYEo{a^_+CNUViOOcGx~Qnm(q=!d+?T6YtU4#uT4O9+8dbO!i|vAEUK2Iqke54QmP{ z;TJ2^inlgT&8m0Ld1Ec}#CnE)r>A2j9 zF>amwj+?pZ7*4)lZcuUqjWqGV06des`-$^ofrmPulXE{r>7a+Yo6kL*6kA+w@g0u| zNkM}Tv=8S-TqE23fpLN?Y;mHDzV`v~S`ug;Eb^BL4aZ%o){<0DeUy5Y-)2u@7sz@8929yB~@)?i<2Y6#bRqx_HuA%}z5dX4QFsLvM zkU0k`b7I;^9=7BOli_!DlB_@)y{#cu9!R4D+$vZEy~_r2bQk}9tBcJ7-uJCK&RDjW zv?S%;|M1JWB|LddtUevJls*dd+#iPD+2afVJ5(OAX9RX=h;$0va+3RR;Ffazi)$nCGM8opclh7;k`|f3d!yvlhbv2QKC#BlnTNlL{^jNG9=Cu0f&6** ze0P;g$~c3hO6VlR)P--9-)p~?LzlrhMTmGM3Hbw2|Da|<_&mrN^!PF897{Ts!;r^w z6z5Q(guHf)n+dbe6AN`1PeV%ygVgG^gKNY|o^?7}fW)-^N`bvQ0%O_qm+7~^p`u1a zkiN?pSd{6cl>t7Upj(}oCtJs#&RTy}t16H6TIpg}THWd0I-7j-8qd@O_J+oKBsr-2 zKB|%)lagVBWPkBB&T3tBm|^-Nk8z%31Yt>KCpE9Jw>suJp-+oms@?$j7Ng4F{M&7x zQQHrb+eU?YnT|Z5lbg6gu0AfkGI1g@F`s`(lUH5qR8j?pOoBF*oc=MB6*YSa^#nD# zvJuP7mYmy~qJpq6X0hGhtX74UZHH zq;TKe3h^8Y_bXjC<9xo~_b-#o`m|4bcGgTiWYJRwLjmdPr$nF62iy_;At2F6U^jIc(DsnC%j<)pv z?cohanS!dDigI0v^|w-21iBf+vXwz}(Uj<#aL;&t52DH^;wj5I#)jTJ9YpG^Ix1VStmx{ApvW z6Q;_nlw9)3lnNWf=KApCMUp!I674l#hWESTbt`uj+KOb>Wcwsdr;yS0JbOtu%>zeI z1hlAE*{Gg|8jLU(qm;_(R?EJ?U%)-m#c-HJj7}pPEPSq1CLQsyD*fsoQb3Y~9}4jt z4gL1b#pm6TZM~4WLHiHP`l8WB1XJ4GLdlaNB^-jnB5lJdyv$f8tIY)G)270r4GH@1 zhN>^N@0I2OdKq-TEso32N%pf$W0GE$20UL^oBi#_pB^}p!f?qKxhh($|-^2&ZF2Az(YJ|7A6lm?zD zj7q$_&v8|vN z6O6QtP%bx%1M}b{Hl8l~%B@P^MwqyjC22XmUGk`l!QwqTtki3>9Ot-rRlk_&13O|G zLX`kGsCwo1NeZ-HTw;g_TNppek5>zNX~ab=JPIf4$U0cT{L;VkNTK3i?e3+pq9*Vj z$`c2I7xcfRAepuHMd9{1UB>n$Btk@aJ-n2uhVAr-StXHCVypaOxd(nHSRBL#kC}}^ zTv`Ow(x;;N-@@(*Q)p0=1~0HJe*3vr-gUn#l)V1bSs3HUF(;;$aLIS*gH+@3Gjq6v zVa5{pq#+3VzRT9q{Q2byk@5cuwwVeaL{f6tU zx5-up)^aCw8*0lhRsxPYD()`bYxopXeM?{@)K)7QNXABL`&!N-Ywvqs8I*!}XWRUb z-yBfAt>|aa&U+Uh$s(jTOeZ=rT(Sc}a6}Fq#^KJ&0}`!ow4%aOp~C)IlXEB4R^wEX z1hqoptWcISu-+e3ZOPAwHWif{5Z=D;O2e#S%*6wopWR15+Jh&!4s`#ZFe6lxk$#;) ztupZpULW!dY)n)|8^U#>IKXK#poXhEP)ji<$JX1V+HULX}NVZP2x^Gox zELhuWv*q}Xn{^30To*OWZSRI=98wIl+jv)*5E)nUD;58)b^xa!>_R!P8_jr{`@fRS z{En6%EE-^ERPn#j2-OOvwQYkX zkZh`7I$TdHDWK6E+equjYcGW9w`-C11&3_D2BENIP?aKNNin%g5SEWhdtbw16eIBvwPqhC!!YH{?( ze{g_#ltC|@d_&b|O@6_f4h?1AV!({nJtr9^ul>STDugb@6>Rd7JHHC~{QIjN^y_x@ zaDt(v;Blq#3OU~wx}^c@Aat)b%96fLeWq;Bkb}uCtgtV|Lne4yMLDfB>0+~szT!JW zfCF|8eA@wU4A80nH%~m=?@6PMn`5>_zzcGB4bc0R%g$W*UI7i6QYFD@n$frHdao*-~U_v}m^i1t_$X zeyhvt8cy=e_RhH`sJD~j3ucs*r^bZ1%D4#fDAtCHDx`)`v^K+N0InBTDKxFBEL&#- z3sA@#_nKC^GuI9!P(Knta7MTHG3^(U_9-6zIE~v<3L65FfOZqE88FqwqNG0r>b}X5 zXvjg*X!gxQV#iQ{$()zZZN@4)Pu*2^Td^--ddhmrnAqhl7C+^rQ9sg(#`Y=7&BNvz^24pX^M@=&nYTkddqnkRa8{3j51-Ruvp2SrzaTmzredhSG?zW-9{PPr49VFT!DOV~H=M{qTt-WrTf-fVOI+=U%e zHA|D1l2;a+De{`>DudY)=ku4k4a-;uRN=y!W=WAKX64 z!>=sf*UaX}T>kOF7NB>D%+fX1DcxZ*O1u^BwQ1=cz3yt8bY76-VCb1V|RUIgb(HoArwO>FtBLi09^YbQ3oX|(yKU1pAleK+aL zYsq+tPevnXX;#H;C8?Fe{EX(A{1nI&#kBJK^}hKpF{*H;Ub6t+S6z`}zEgPax>W(0 z%-D&-U^atD7Y{lcwJ|mNOgt)qszIMI^^{rcKi0pwrop2X-A~3vZK$r|bQ}_7hee};4!uV+O3{h5+w-D-(!EO^U(m=YYFbKVN+}jO3}KPs8^RGy zINhYrU{K50V@#C3TJ%+dFr6QL{U`p0isMAs_itD%4|a8vHI&R z5klFP5fY86Nj<|RWZXY#KCpT9Im#_JB%*7wGf@zH3U8`U?&wFYt8*Z$D-@8|NY(;E zMgpi5)EUkoI7gxxaF|U(C-O^hPvvJDs~&`Hj#1R>Ric-KK6zN1YBHaop_d)GSxPjK zb`?7la(oNE6- z6;uILw~^MWwhW<}|1cyR;iLcms)Q)~8m#jA~CD9qn<7r#~n zmi@ivQvmd-AA+?a0~NS}eA_zIU7laf*)FpN_c$`N@nCVW>iX;`wMyeD+TZ3AKx9qrzabw2f8MoH5sjTo+RI-G^ zJj#Ry`FA`HXakYzE}wnOUwq;wvS2ryVNZoL{O45Acja7_LQsvd!k8mcv?Tz0 zK4<2HZ`Z3yY2A3C6DurxYfW+#@rT)+sv z>0-Wq%9g!PHDYxsVa}{#WQqqR6N)m}x@+kGzuT6IF4#in?+c{PYs zgpk^2O}scAMbEwy`tp5u3Lon{j|gjcifaB2!K-lXiE%cid|6|bM~tNGHJ-WX59LXp zr6nkEzJ3e&&aM{hQ+N!rKV_Kow=u|qNx0j+oRZWW21%HBCJcStA7gIx(D{vQP{P}~ z`1`Ydg{{SyQ@6Ug1gTHzVmjRpfz7x)-xrWB`tcuYlQ%+3^=AiJP7o)4iyUUh0&bj` zM9n2Czl3`I7Rj&HI*H0Ln*6E}Ne3T!+-QsuvXIMvYRmg+u$aHVL9-nin_&aZcCwKs zHWsC{z1IHJ?~?39007E^dVTG4^ox^m7IEzOqzv0uM6WD=msG9IB4J+^KA z%J_fq;ri3G^~)KU*4S7lxzdp#aHL}*h>VA{UZNtzy`{QYh2K~M2fM39+B zrK*b^2!{tf*Oox?RME8wox((6u%eR5WRNA{83V54E$sWOt3&4U?-2c^457 z<7JOqaDl1)c4^v*Ld`q>4J%qYb=Q~B9iBDXcK0u->#up(vI~x8abh4xL@owK9}_Sg zHMp6L9g3dn@O}@na%6&&IgmdwKTN-6asDL*_R7loW0CQ`o1A?PRbOCex`5j>e@1%f zC6EB?C#F>^A!}qWEiYB?N0z5;Pc~{)?|e&0diYOYxSwrCOnHM1EITqjh3(Vk7zziz z0{8a4)|;vNX|c~qL%YXdR_))kUcb}M^%>VyfU)O0anP*F^5H_ke5uw?Uynto->8 zxUb?cIQ_yMXBo#zBuj!U$i?bxS5?q_cfm zOB|a=mXWGs&zRfU$g6B5aCA5k#%bz5%mdimj4q^90C!r||6ZZL$to14EIj^OAv4>cl!?rS{dmmre;&as(5L;;*yf;EK&@g5m|)u z_Y(CL=k;;36nByi4_B*6TiNlvX<0KEtP@`jn76Doh8~bpf0E-caalE^A<2PcwaMUF z;_r#!Qlph9Gc1!9WLe288E))@vC=61R&PWXX!iu83Kx4uyZ?^G7RGJ$ zey@{RU(w2xqM!U*bwm@beQJ=CsRm+@B#H`Qt4%2y1_PavJAElX(VDb0 zPriRgW0MEP=7?vQrpl&1oPwhB8l0bM#irQSO-VyK?--a{9YRN!tS&d%7nY(XB-j9(T32NdU3FtaH0YdWtU zg*}Ri!au=gnEu0~p<~)U9n^%LXr=QJw8#-|WM}U2(z?NddCm_;mix|T)Bck?TT#wt{7+4T2ku!G`Ha{skkGvOnz7!Dp zDCR$ia!RHt&YJ4Kj9p=is>khC{ho<&Q@ZSJ8Y(*-tA>w^exk71ErJk@N5<;*Vz}Y1 z6f@JJ(J@PxLtu#ft`Npxy=^Ll&sU=?vfiSZUfu75S)3-Km_>>KyE5CCasoY{7@hE$ zc^MVtSu3R-WREfJ$ms_{=42_Gs1MsWi$3p|GN$~}%yhT%@aPj>_sIG$#HM4683~8! z3ZlGWo|ivSJsx{)@&Vd=_p*&_z{6_B>!Utfijk{Ll{`nCpAzrKPOdoIVyH$nQ{@`i z$a-#4KO>^3kUKwt_kUyVEyLo9qAkIYAi>=&xH|+Zf|KCx?(Xiv-JMin!QCaeyA#~q zt#Iwi>-V~Q=Ii-p{!Rb7cIw<)w)Z}3@4XhedNl(fuH~r)CPA6V_2@|8BLU{5(WDjIERKL!6|CAuLD_~hX*7n3rC?FxGrVg%O5(?aW;>u3Gi=k9SlV-l8c&wTX6(1|Esw$-8F$R zl@L*MeacT79DcYj9zoC-u4BQ0x|CGt$9`770dgX|WFmKmpF(1S2(DBEwv*BAe>E$M zrD>DbYI5vUiu@4|TuWI4DJ0+r^BlR*16C!AXquGN{HlhE^_?+}7F@}54BFyOOPL7HIk5_%sCcKt6dd`OG|fyi z@f4^FLe}?6EevbRzi4t{n{>yQ*V`LI4&E{mY7Kseo|;W(J#OrFZgMMC#?3Z zI%SW%=40U3#rEVED3(zNaw8rcejLm{<%3hq2gpjQ5D*e>eFS@dM}yvFYeZMoZn}f^ z>uUWtMD~*+GZgO>-VD%TCpvZ>VnttVp;v;8hzlN3eL?WV%I2g~JNk(wYTfSnyL2jY zHM&9sQElzp0b8+1h~wW|B~}qH(s?W86+Smg%qICMc&(6i1@*!7@VuYv z#D-hRl> zkeIE@smtLU>e(((+VLkfYU&lG{f{2=ErymNC?kC{n6;JvRQ5U@aEPGmiA}aRheg9` zbrP!ck;R>1Xku&J%hu&;mP$*iKafem3Kw2;s2FH7@ug(c&&a)zGn;9&qX}IyKcbXQ8PplX$JGNJ686_WRV|-}=_`gh$*21+zgWcaB+ZPpNPCDfRf?TNXzOsOS?eG07 z9$=O@rGO~QpA)dXCj~5X1Q%JN;Cn|2lYBW!r*jJ8MJCBI6Cb;{RY?yBDKc74sI|sf zXiNMrEPxKLpo_>%^Wf4KO|Kb6@~#UGri(T$e?8@t|iXH9Aa$I z<3PvFaFrD=U_adl|2AjzqE|@58o-cygo$O=!&h@G89;CdQ@OHlK>2se$RZ`%4VNPkz;-<|KuO zfCRW!t901Nj2sVIir5*@Y+wdxtJ&TpB&B2v4sWWH+}0I)2(_Y%T&R2v)~876k0ZWO z`I45oDrHv{eG$ZL7~8x3)05c+p{}3FiIHB1EDpo_LeL;%UOE@aRGV(SU|NGKEFMtB z;uSf8KrO`xvaM(OPQq5u9MV{VVb2YkD@hXMpE;#BFHk zXmypylp;*6=g>}tL%f*q!AI0-nBRb?i9-A;6&QvbH*ehg=5uj<`ym(ldlp=tOeC6N z|HrX7kSDJZU8WNeay9f5j`P-KT61$ygA4JVEXa5`}2g#cKppvz2o*Buz7 z*S_F9&8SLgdNQd0*bCt>5Q2@OaQkjC6Am-8J>Xp=^&5?i)xF<#w&nta2hiZ9IDoW! zAKQt;NChszs=<;_#U>m;h?u7t%6nD^YKa^rc@5-;$x30xqc|{EA)4Y%3R{WFV+=~v zsz$AOOgiz2YoqNwI?nNUM-21bTSw!FbfGh}C`r#-APyEF{k?KqHbFjTap@m&)fs$% zMizE&w7q65&n{Cx%jcEkJSa!lSe54JAx8!j6aBBTJp<@G>gwdsaEfWBfN>=z?%062 zfp9~Uusm~uGN;hw9l2C0V5URIo}%ZCNNsM&C=>Z^IihDY1$9TuP)Q@-sU{Le%Q zz9% z``*E&C91aH5aR|KC4-PP{)4HiZvEChcg+v~ueR)e(0es^1Po_U5<;uF&{alkxaisx zvtvxH$Ah!6%Oy{FZf!99!0w$3lt|E2N~EWGiIz!Rc{<|xXqMt@_Nh#o3qx8nP;B^1 z2rc6Lk+!mhXSCAM=;9m%v$?WA>;2N)NV41Y6vp~}Xj3d-xD5M{*ERUQgOrzcOb zp`O;l9c9E@=E1-&EU#K$k)jy7L34vCpVt~RLKRvYli~_Yo#2HUm2(lBM*D6eX%FMH z21)3hEZ>dqWg+J7z(Fvld#H|(d=-dD@=EGqVvkS(=#o9668}HMgFM^;#?%!F6TMRm~1$KN|6m2%CN{;>ye^ zv;NKFzE>4pSr|u64_bsvhNKWP>^&F~r=UjH3T006TEZnm3{%~hwO7(I>+-=mE}w}- z8cMYg-RJ%^>vGy6DlX?xErfRBYq1-oq~E39 zkhYvZ6`a+7XrgLzEirqG+5D{yq~q2&RVxkbmW*m#CHGH|ew!|4ndk`jD%Z4Fh#~l* z!lzS1G4piyu~+VBmaOI=eG*beQscpvFdXtp`O?jZ?bRopu{yRJ0bk!v&MO&@TCAkj zSIEgZ+vmWO&(E-JMjKdfOkOC?20PmWX1t?J%>vQ^>Z~Ez(eB-xqtrUNyuTWnc{x~G zpS#rSd(Q}ELMlIg zm6u{+6Pa)*6%ob<60DVkKt-`=CqmjbY??CZU`NCpnk^y__udEBJG)jgL8_ojyo>c` z71dIu6hz4%AA|eSg^YrO(U{2)VrFRlj>8&t#M-_@u65e^$GCZaYALO#xVt}Tw1t6A z!4bEGIVwcIpbVgd{uoRNjqxS^8B{8z3Ua>FySOKv^x0_G|8v$Ji=ouVbq_R-<;}VR zICp?`y|a~Fm1y$1-3`i>Sh7>Gy0lOVY%eOVc_6PVGXV(6X_7Qk9Qcd6x7#% z_frISI(T-QT6{v zv*Dq$cZXrC)6&7+%Sa!xX!Z&ic_n4^ThoC$wH@BmYL@+CrOsW8KqtFn@6^GaPp{iv z?c~{GN(5mjWDH>=Lz=JMWR=V+>rIQmMzwCssk67A>2_!?WK8JUh$&<`225@w$QW)L zb>`>Q|C4U%=-C#qgG^+<>G7#Ek=tSQ#N*Q-AhfhUU6je#hy_CpL2Z(94%lQpqanMFLZ z&R5m2yDfn6Sx*u)^;cNdrL|8lB>F726(xDdhno;+OHHNRlGIPG9CHbbF|att_RSXg zytAsQin1c)e!pHlu~KTvrI()eqdNu@O+?cO$z=cc+$I2m|ozUi!1#N-KY8b`J zfwz07b&d;&1<4dTrs#q zF@UQ52+eD7xWIYM0?liuxs+moXmMW_e*S4VPYMyd4aON|<6s-{YW$rU}sBy%5?SNX%!>2Vl9X#6Ji%fmx(8+SK2 z2a(6#600*DT!xOF(@pRa`L*TrGsd`v*a>6mGj1G%INg4&iADR|M z+dhjp9Gxp=r5HxY>G*HW0&llix|3OLX`iW20%iiHJn?q`r{E^f z87o##YCbxWH7(VbAUk1v#goDQqZ9R4Dg!dZPbJ|!HHc7Fxd|eZGyw{?+pJP!;e^W0 zG)ywT9je{8I5G2gV2ZNn9-}{bkx(|!cJFt~%>7K%3ooKA>iQD^{TpqlcDi8WAmd+H zSs{G4vC!sa1MnBeR})^gY0%I-ty3j!yNomwb25v)5f;3^Yd-vv3Mnz?nAq^Lr0dae zG)loUjZr_>kmtM}AAv2{jEXTG`BG!mtOl#SJuN{nY7l)TzqGgbp@)_~UZ-@LZca`W zsKiD=jg09&#cIuH(;h1ZUdgJ_n@!hHHY!*Bsc4$8efU(%rcYyVw`KM&^Y$bZTXABAEn*M@*ncfH zZiqxWh0YHLD-h+&OA{p#y_*kFZ+;ri`Jqd> zq$ZdQFgX;oYIF0rnoeK)8rH()PBK*KsS-bYrw+TCJbVg2VLt%B*!#Ur+* z_7=p#Y7a03|1-w~IN8pI~+Cp2Q8L+rL`n+U{w`-v~BHAC!e>J`F2v-^M8 z3sV~z<{^F~?Vh~?@^GfStwxYPySpdL?7#PsTKB)b_>S3d;4`15yX3!S{<;Nvg*c|( z3G(oN-X{O_^q*sL!x3JEcpFMn^M8)1y6*FK=-KsNg&zrX$-q4SGZh`bD1X$Kb54Eo z#M(Oj#9EGe$PhU&6*=Ai{o;jm%ip+>iS6%Ky>GDYEF@|Fg$2ZBC!s#8zc9S~YMB(e$dW-3*30M7Q97_dlhy1r1P!nCvNx2W&zO~Ea|t2?%NG{ z>C^*eL`_R(S|m*Wk1Sf42w;1!sQP63>!~1a5Va8bpS6O+8Jur-=W#C$@*d|&q_6eX zG)?kHc1Kn*zJI=9k$!HGs+eNdv#4tB(X4c@X*;{QD-wKLfKaSWw!NOBFhIJIn^DM+ zIG;@};fIPVrvEk-EPj-}aUicLJ@2vPm958XbH3{YER=n&G^8#tprVWif>%M3ULp-;YpCr zcRi47{hm|IFX~XtMgiwxDSj*vbpQrml>hMGc4ng5!pE+u*Yn62Q(wPN9t4b$gSuCr})>CQIbxFjYap@ zJ%_USgk2yKzs0osq7*Z5LBx|%7^Rfk7#8qYwrV-j{kNEZx1$_dBfA?-mV;R*BWLGt zK1U?h6xO+gvWTnJlf`YDj?xu|Sg3g>! z9@o`kMqc4n>Qz2zky?oecY)L!Vnllml|$5(Djd)3A|}ySrjQha`VFqQ(hH%?SRu5x zh1ktx(tiq5Qgvc+Z6a;#P`q33`DS==Rs+!Zey5Vm$7&& z=rH4^Bry5(KAM@qTW|?wuq5N_u5PooBncY~3WQ{|?W*mt*LpFdevKqP<;k4UsTLu$ z%XIMQPkF8Zd@0+J;6TQQmD4SkLG~mDeZ^Z6rCn^6S_!G4GdO0$O@mY0cG~6AD-ABa z-Pf|q;|;<4I)t>m)wowL3tS*)Mk>t!;H$N_l4|O@Ul~Y{r%rWIijD^;S+iQek!>5A zcJx)x5QLtQhs~zO?cXZYvB*Bqb>{tZ6QO zr_B_M%l3Zz6Y#=49bF@r@&2Lk?UVUIr>jI{k_E;}XJ@}-jYjm2QO%T*<7@XX3R_{| zM}BW?Nv^z@lLf)Y*^X^-{rA?a=fQ6$_?=*I7WnzStLQz0#jjhvmMKL@ro#Wx+-FVw zCA5?E-k$L~M*licusehGXA@}sZP0H=|AB(#X_@6^_q{{z=hts1k}N`x*SNlC{*UD> zW0^t=g4+r5FQP0j&iZ#fg73Vf?;+XuK$ch0?6<$2JD43v+6ZReSvD`A$yW;h2ab+? zRR4i&``5U)Q2X*XWRKL>9l`4)wD(JMAEuGx?);7Gla6FY`r=pa8%(2I`KB)H+QkS` z$NQv?(-3`dfA%$v|LmY|cbd=f`z00R=z_;n>>QeVDMY^c+4$`+s$=Wl%;{Gw@z)uX z4?d_g8egBBvu-T?4@2~CAs+&Ec1GFT%*MN=|5KFSn`U4AEqlwWdYqAeR-SxLpLBUk5uoxc{K73-!s5NPpr(e^82CPK{{HP| z_s^2u?M2YYVKv6;U}ynZCla|Q!xfaFyrL>uC2fXOgtLP2#eP)PyBg@v zlsQZ)wJLPN# z46s!%ZidrphdY8f_cCq<_;vK4@-OISmMS%zGF;O;M0O-i?4`~I5U}PNKDL30!^Hx) zYzmeNKf)-J@u-I1Xj}-Q!ybSVu=p6NZ@Kia2T}|Sp z8?%dMTtZ7k${t7c%{|lO8~8QPU`7L$B~@Iu#fEr^7@AZyL~+XaT5!~K&_mpJ-uNgJ z8&K^59_J&(4Vw%r^LC!Vr@x+RtbeAE?m`*4E?wi(3oSXdp$5(elslz%FMDEe!L z%ZO?Y`;*2tWe7E<_s8xSj|`KU)YwS)3HFxlmA@tk9iW0zB9uQA3?^7_GoIiDbh9}I z#n>Cd8#A7L8D9b;eDx&VKf;j4*Aunc?-4#ehqVMqVp`%H-a5SOi6Z zS90XV&pye%_Slq6?Nl6wtRhT0+?##9cd9`vJg?wmFehNxM%W8CuTF+5gj2fP_xU;&FD~gVyLmX5F-3<+(s#JXPP01BJ+G5*g^-Z z`Ir}~Y-l$uC9%$;Mr1MY!>2wTV@0>j1>WIhL+~)ZDvw(@s#vsr_EmqupU0Wn**F%uM%9M`e!rT*^ zfB4GJO>a~Qus(TB@0PgpdfJv+;L(=7r+P|R_WXGH>Ed#h4bdCO}O> zh}&FOwH~H$o$IItj@58WE5tF*=(Nx?-&AxEuGO?rZn^;3#mbUF$Ej6ru4mf&0!g2)p`CSIl$fhoVk! zM-wd!)n3)PV#D`!ze(LD!I8tex~zxcf}_47T3`Q>-`Q9C0vDYvXE*2zlcX;p_ir~V z7q21x+b@oBFSLMsNCf9G>1*Hq{?zUw_9S|MT7BUycq#W#`wo#K1o}_#;meV}h7Yoc z4`E(B;$50=yb^dSOw3MgaS>?evdO-po4fnI(Y)2vC0V1cHfQgwwKY2qHt_pTBEETC zpHlI;>4ly99tzwG+;sGN2#*Z=Iix@nl@&$>^Zd>M!M!Mu!k z6Uf6Zj{^Zu6ExvpPe_;S_I0ZUnofJsy!;%K0!a_u{5OIwYJsOBpI^HP?>C;Oo(HbB zZ*G>WJYTAw1t#C^c`^U&GZDDY|4>VSw;kmOgYem=nrOu_9?_Osgk4KnwoM6NSivh; zu$%0aJ)U-TxLE%zCAZe-r!n1U6t61~F5`Uo0%`akn=))Oz5P+^z8m@VM1ja`M1iMI z3uL|z>3?TJ7fH(#ntyhK!FrMd>m@9!ruM~TVOa_$Hb#d+@)cENh|@L(+kc`V`|cXU zP@WQ>LD-8=1XTzyN`)VN%I$JnXAlTr9+3je$nVHjIa~HJ3nTPFWgQ(d442Ur`=zm~ zj+PC{d~z8jzvaeEYL&F6A2f9aK!raS;$bop#hnc&f6e61VzDmrF$_zsL@n57#Tn;O z_%|Q3X-FfdGEGA{oHtTXGqygUsAO734l@Mj7AF$}e`3x?H*VeG&`XtlK#WkLK$I5X zCdFL}p$B?Kv+F-FWmXAU$FC`HG!dhhml&u4=aX5}vYt7Zye1hAnN*6f)N?(tW?=^i zaL18&EqL3eT5Aw25l7E*h*fftHO)u-fuDI0*@iJMk_UA5nYSH z?&Y-DCU-k&DG+n>N+`exdn$!0H7arfx-A2xD#g3UtPSg0EsbP+lyue&z7PHv7LZsl zR?AvE#XrR z54w@WPBTw}i~}%KU#0D{1y5f`$C{w^u|cenH`I;%c-2_PX{9^_b!u%;+g_$V8#%4sf7xE^j7iZ^g8wBXAh@@XWi zahEjqr!{<{$mOrIG#RJ;;ffC7FMzF;X_7_m3E4)vMv+nBUWDEMS}yYI87fdoX>JUm39&aS<&))cW6@d?N*Zx8>ksQfCXy; zQ4E)jj?OA2XRHHxnOsAfF#+z={)4u&{x4p*^ zVF$Mv0{X9KLEpdcSNHmNY44A5zRK_vz_-_`=kJaZRxrGBIFql35UTA;(CS?=KS>i~ zkP-+}?77_hjd|F<^Imr2X~zDgQ~woGOv`#)x0Jlv(drd;^!y(8$~MfE@WE^G{5YszQ7-TPdA2d;UZ-JlGetl#yz z{y<>iXCGWVEq6rwB)C`7nc<)Iz8Uk}@oM@$d*AVN@!FgA{?0tm-!N-E>C@xfYUMHe zUDs!D^!-WD;}OD7j{E+Y)^YVN?6tinj~}XxP*_KDODF%Tv(Mf69^-!z_I7>$y8HgP zR`rx>?|sDmwn=zW3SYf9w9_+#8-+*$UeSM3PkoU0yYa6Y?GI{*&2eO7?f{3qH~BwA zzVB)KU!M4H2?&bs&yw7q)+N>L72@tZzMh%wzZXzseFv_)R@m)x*?Wus)mqk!sq%hi zc%No?OSAWXd~Z9g@?BwAO6dzld%EwuBlR0(cw6Rf-?jGJH1CMoBQ|%RA9rAlEU#87 zgky;1c7{y`8S7UhM(DQrf9xix*&c_KCgbh!#fjA15Hu8u{4E38bXipt!FHPnVH#EC z*N)76w8rCAgvCbfUjF%~Z2iqGY{d3w0ebZTW10XV=E~vdC2V)23PJW&Engi?$>qEz z=M2TZ0i~jBhx$7jke7yEB7}R#-jAX4rVK>^Q{Vc4;1w)${LBU~bAYHCyQcHj8L*$Sl z=x9|WrAA4OP=P}8`8oiqmqWG1bVNxw6mF%9jra@uFjcT~u^F9MCGXFul4>VMH6Fyz z0VKS3$ zwlUz>wGJptH>xTr*iCr{gJnQiBdOSC6N=OmeZL_bA{kZ!N@0|%d<;K&&+X$0qU3*& zHD)@t6zu<@9ThMtC)J1k{xKSUDX3!=spEH52t&e1@^L(N06d3cs4lh1yO^{+5O0JW zkt9IKSHHSk72&(_P74PnIZb-)SRGR`&r*oy06K^2)O<}ru3;umlsIu{90k!B)bC{T zK$TMHc|xG+iAj-jsEr^gT}?T>aCb6Y?oN7{gR{fWK8?*$$lWteqtzvzjsuaSWhZD(|~!2^COooXxza?^myq z07>$-shL<@w&Jf3%^1ZTHDfx86?PE_3F)js>TVP4rLRcGj`5lq?@cYg@g!a)n;CTC~K5!rnm7Y!H4L?@^1nFvG|MU{pu zL>J~4e^oX;1YME6w;*n&s_^6BbBXmL;^6a=Hy1X~y1Z_$-h)GXc01~eFapsi$-m6+ z|LR~+&yoLQ4e(Os84x!6ym1eS{htah9+uPvJ@nt;0HmwBaev=`+x`3>5q}$x-iE(Q zfqqN=%fe)B5p$HC)*DPEC+#?tO9@Q+5y{jM53zC13(3Pm56^6f;Sz*2Rreu$g@o>i zX8i@DHn@A(YkWnI%~@5{ggF{2%kITU!wS#I&k?-eKVJ0hTr)Krte748FDz~?%~gqe zG$wtPY1}7`?YO6T|0e8mY1HO%`^$fdb=YiQ(UVg{;UtE&2gCn-t;+C`EzWO1$@1N} z70zze9sCj+yn3(Yjj5?^^-w=3pgjZe1|tc|S_>>BJ}1xqID1R-UI7j=?Zu@Dny=>T zS33>!6!zXKEvuIke~z<#Mn~z7L#sTkEBO$!YHtysH44wK440L-4l9&?F(0x|2@`G;sOZ*$CXH4Jw$GOs9+Qf)bkFw6Zh{y$iX!)W94 zi40MOh_--4JE$-xd&9CEt5#2C^Ra=;7`>x3P~l?@ml4wmC%d6}ysPV=k!*|EvL%isls$i&k(ROsayZ3eP%$XB?uD8E#qpZ{Q~~dm&P+D9Jk9lp@RhZRAPrc&$&VN~Lb$bMEXdlU%c7g=E7c)=bpUj^FaZKnH&ek1GF57SLoANIouaZ( zVGXqIDkI9FhJ)OYfOk9`|K}3BCA>y9b}MRr973B&+be8NSzA_FU3lg)YWl9kv9wi( zV#}W3<4E#l4)R_aF_&qB(xF08zLzfl=2f+#3ePYE9nrej7Kdae#sGJJ;T*St+XFP@K*ZPj*Qm6+1{Og z<#q3N7`*H9c82c{s!oE>qz3Z44?6!JVsb|a^nL~VpON@~RLzjSabNz+hAQ6TF0_LW z^udkUo_}NDir&@7Mzdb zZ~UzcEsMp~B8oIH^2C7YpZeDkdAM0)JU?GQHdJKZVB5wh@wAw?e+Fs}1-v^XyMjZl+;p<=k z6I*?sjv6mo&e-l(-_l#{y&F+T5jo!%5^63i^eL=6hX z44_GrB<+E_!E{zPPh9tYUy%mt6J?8rsn1vUVfLE)_RD8ZI3sT-0b>XbW{2S6zT?+g zI>9mL?wmg;wC&yT{4KEn69uXo9@ZQ?;61q{mK_`H{FE`MUVhLop3!-~F_DGUS!KHbP zE=vG-3N`Dg9+fmoR1+61j`fRLF=EYiRro4GE3c8iy-vf8dn49?dY^m#_$wB9D#@ru9>TY``MM@&l34L{(PM!{0Epjp>5w+vmXG zmH)y5!Y5EUSw|u;1h5GUvZIR}{KSPex(e%#B8pSX2ORT$627Hu0}Uf504?XoJXxjA zUNaumR|X_~59$E3WJlM;T%Kn&7~tdDsHEQ@M%JgXHm9P){J2M62X^CFIPQLVIbW%9 zs(q6A6dYSGP(Z7!(GCsJN*$EiFPWOMMN{Gvsnqh4TsRS|8kPjil96}R^7Er{<71e` z1S#|-b?DL{z=Gz3f`$W|vXqbN2LWl#kAJfF4#bFTlz41kD$H29=hxLi@m57d5l;rp z^C}eWQ$@Q21D?k%a^`)VyLhQ>N1h0B{cH4{)-h0ef8~OTLU<)7NU4DBsa`w2pn+EJ zeVZa6Z~wuwP21oZ*k+FRFTS|kiu2Lw9Po@0d&=_|U!L%QZl7j5cyZYig9=?fi>gQ@ zmoao!%~Qf9dA061G^l7X6-4V3F}}eNBku*Gk?-s12lIm;DT_RTb;_Wuaj8ACR^^uw zNW;&b%~>`hLP_Y5GiaME!6GcY=tSx-fS+lSwa_E(WoVm3&ebi|J^0LKSdpHaj)Nvs z*|Di;godUV)8P$Re~5s&4Y*}gwJmi}lR94-_5QX+sS<1Z4fQ$8?S=A1%g`6rC+4u^ z(Y#ps^JHzGvL9AmmAw4IReiE8fT4ZujCBJnDL_$_cdEHIxzzPo)GER|y+qfFNL3WL z1ARidDqmT7#W#6mDc~A+MxrpG^nO#17K@U#nJu`*K!VDj1v1k=hsyHiDN6!YQe@G# zxjSe5*;++#0qo+>4B&oQu~VKfxl}^9kcp5a@}(?^0z8 zGZSH&mr33wv3%Cx{NOa1uXo8Z>uEXqLA%qfw9W@ZX{-E-<6+7!)O%kAdGgp#BMZc* zStNQn)^IVV+zqljr=bHzD)g2*4*`=Ps z>_e=NM*9GVV<0jXFF#>r4HAMM;d|tcP4k(G`9kQNKKVgFk~FQw`7sj2H{ z8%U~81F5BS7grvFXJ3~EIM z{2ToJk7mp)mr@2IkDU6d>-4i4h}Sfs9alVCL`Ek3Jm=k9%+dR75B3lsl|*}*zV%li zaWw54D@fyNBY*B$r4LSid$B-_lo-nQG5+T*>`Z6Ka2x?5$)zRKswp6(D5>#q>jz{e zkC6RUP3Ri&h4}u7*yqK#|Bi40Ag$n%lRnd+IKS)1n6Q7a*82><|J9b~&`!zS+J~xW zTz9`6GClGtr|dJ)N>t7jf!fWqvBGr0r6#vcc&nos9t&vLQi~5;ja0LNc}v@6hUqr* z#2t4Pg3)L2?*3z>9@FZt{@M4~EFQ<^8;)n_cOznkI61qarDobCpUg0rB28W&SUSXJ ziU4QMB%aR#_U{GyLWXpCuz20x=MSIz;3_w)<)!8vQjdtK5@WOFqG8+{#E?I|bP#sB zfM3XvNN>~4IrrGyzXj}BuRkFa85(9P&c{nxr#ENzXyXBXJaq-#m%40r|GNLJLs;3Z zO=<$+C*Rsd^;^z<{gnMy-_ORU4p)`Ye{&u+Wj_-d5 zhCKe{`{MgH`og(MQUF4160M4YW8_wp;2uNg1C0Kox8R;UmfxPe_XKHuiW-m1VbbjE z%7mT&e3IZ}UG}SYcD20&8!Tzin%Fo?*VBxpT#Oy{`DpeWHTN zgvCYO3B;~VHq$-qDXZT$(Nvj=7Ex52AuvlZv`mvrb)SpCeHQlZY6C=+5e#`UYURxk z@R`yST=h^|R%@0Uu42?eYtg$vAbj>MArDA>NpDT;aq@tAdi>(bu9hD8TrGa)z*iRW z5SSP{15@=y6njJdRXs8=$F!bt3 z@VZ4SB(#jL^}I807TNxKiF6I2)BzM_dbE zyMTJU=hI0k#gT=fitT$>Tv;f9IJ4OD~pCpBouX7@h_ zL4xzZ#=>Gt41);%fE$ZQ3?jyk@GVAW?Zl}2Vx=FoYq>q9l&T+WT64qGDLhGxxK;{^$U%MfKYkyX zKd{CXl?FeCiEyI+6!L^a(Euee_?4S&2|ch!V(4aAQ)&h0+3JZ#S16NM#{r>Xnu7ah z2rv>kcMPZyP-wnpccCCR*#IEC)YSd(%gA&T#U8w$u96IU#%4v0u*gc{RizMuh*ii| zg9IGa>vQu)C!qWK`n1M(Q!u7&;`M}W^Gvkq>xYunfFDLMWRo7C$?r7cq--3N)M@40 zr1m*1;ieUx688j9Bk<~{OyRDOlTB)|*7QVC8(kB%new$96X6hVFa*N;S$UXN3piD- za89QeBQoe?V3}G7ggxRC3X(8w_=c_3cjkx^VdD8cwo|3?h4_g+t6$|x-3$rvhG$TPB&;roQI~^#<6-TeH z4`rk@Xj+E|7HbSn=sKDvE`x3kcLYc;itM_+X2;+0*7TlXe{Fx?<$No<0vnTNTf2U8 z^YHF;Yi{o;Y;s@QxoTf&DcE^kEc330j3l`joUpC`k6p-%Cahi_UQG{Ducc>le?PJe z`Zh36F2|Cb*lb8v`BYyVsGV$-cKWAgk)GT%)r<$X;e+&j zAulB5R$L|K7SU&zEk~EqQfD&;(a~h8NK&IvI3t2;{wD4H<-YU!;Qj3J5&5nC%J|&p zrRx1~@?l&4F{yJ4zmu4POQ=-Th}yTsJ|9assMfJMM)AUbQx_E^c&QlY$JZm@_83-3 z+cFyM*tT;cmxsFQ@nu@lJn0yEgb1HZ8l`8BZ)UZnmq$}7s{hxud`C(beDHH5BTh_83E{JjNX6M|hB69TJk%_vRsn`metzAr3i zp!@bm(0#|=z@sQ5spH*{yyx*jUvGMo&*QJRzYH5U7t5-GeCn?~EKkXTS9$u+D|NiQ zH|*R(J_o+`lATSL9ZfGi-u{rfP+Fdq*Jr`ANuS}Ox47)5`bkq|yUpy*WBVqBYGzol z_WFC>CI`vAPG4N}i6l+$u&mvFxBI>Yk$}8NaI7?HmjC9Ojqio_I?Ot*&*sxB52m%e z_Gy#A!M*=dk^k+1&kHW#>O9W#J?P?XJ^P^*cZWCS^F9ZpvAoNO&xOH{cVN7AbS@eg zBfg_dJbxn@SR-#3BzNKLoy9i}+bCy8VKAu~I|{D;2{)Kr?5jDgEme&Jqk|<9m(rht zrf0;MR3zKdX_oUgBVn~;gG6QxM?LTbmGaZ-xFk(unFu)(oG2efQABFZF6AVV6hWDR zEX<-aEJDt~lEM+=kDpV$_&hrzp=3$0sI}A|dtF%}m4j7M72CnsYoiPV1_v^?NF0;o zVMel0D7r(@bw|PyF7-DO!#SA32<#X`VtliHpdViUjlH)Fsw@1u$C5yBcXxMp3GVLh z!9BPI=i(mRJp^}m=Mo?|!7aGE^zi%lZQstcoqp+b+Uch~m&-lp0S?c4_TFo)?RCz? z`w3YqLca0$LkK@liG{X_yQKDaRr`J&kTip}S z{1h={nhlEGaI_IbE3oX|5#^R^`bP7F!n%PBMi zSe~(?cXTQ;J8|TTB`PS8zg52Rz$<-B=^I_L#caKn23fUdHZ-;EjlPTlpPF%bvYQ$y zHb@~zU!l0KwS=M%%}mZaN+h07(Eu{%v)yZ~S_pc|s;X0={l&<1dGFsO)e%F~O-_>n zRTmTaUhVWBd?8g2t>V5h^|7hR$8*jz74_EXHo*yJ*aE13E<9;^d!sa*b80T&Os-2u1t|R z9nk~^Rn+Q`2+vs2m60>}wry>cc;q%zqW+{S*^d>~LvYF?jm% zsPW$xy=de*(<=6$N{^+-4Pk%S*MAMd$fg$fhF0iYR7BpOfoWaqEJ7M2y**IRyD!pf zz_X=uK_|wQ)23NbPoJt2L1>6YcG1rn>!}?=pJR}yrBiAg6EE|MXCs~A`0bJex$$;khz1zcVn z@{$>Bf3{T1uGn5xl~u~r`Zg%D{hdS)idBwxiU-%H>g(W-oX)SsJj42jgS4pmHhr$9 z^JE~9?w}-0CuPV$YV8*JI<}1Le8ILAPHC4H^~ing&e|c&s*H^(1r}R zAgh4KRke?`HnF9@C^RlB(1{kk)TyA14qCfdCvPrwf)_oDgfH#UjJ`i4e{6ys%I-v; zy|nL7t)l#nXy$KMP7uz^2cS~&tR4PTc;_*5wf*A6d;O~D?GGrI!`IejrsD4UcYizM0Zm)wBdF8S5})0YDAt2WtwuYQkRX2(}&v&`l72q)twH^pAT*EwSMl8-K4 z(hg@jR1WcLt}yG;Mof3ROo&o0{{?dX5O)Hn)^XmVuTmn80o!rwp66ZRPtI}sD*+Ji zMMh^snrh5Tr=#HTK=!2v;)x|fFyGtQyx2F)GM?_mJ)^ZT@qfXW5`?b?RnSYCU63=D z8R@KURD;8I8PA2?=V#qF(A^hC9fz4lceh$=9WA8)7KAp@bFZdzsiKJFCgrN;?J!so z(sX|5oag%7*-{!rV>^1i;fExBmbN1EKiK+s0CbuumPJXXU^J1-g4in>A@x5-wP&OT zb)TLHll>znzG20CzEJWNdbYpo0<}0<)-g^B)Kgr2ZW@Uc{&wY0e{x$d^=0OyeiILE z!65^3F?6+ysq1LC>!IEMIrH@t`K37Eyso?L_ZDs3ON*u8Mba-0e)eF1Ch+5B_w3=q z@qxC=JK?AQGg*IX^wj8*kKv+HTN>tDNn$2kyEgx{*oyS8f7DJ6W)px6A@#m**C zi{2}VV%)^i>Tqq!-hWq?Cj|yo+tZuZpWvPJtN0sqrX`-1Jh~oz{I^zsi*ZtDChS45QjSvuC?=lANZHn%xSXhf!f z#Chfs>&(?MR-?Rs_3<~Ohcl6h@qol9O9-7{=_*x*e#X(YP^RqoZRNQCF>Fw-B~y>X zP7kmL!fbQmrWRjU5#d1l*QfjfrU9n?z4T9;87jcXIGsu_H46%k30gg`pQq{@tgx3H6W$R3 zf1W4}QH6|vwrZ+dNeOiUsfiLQO-2<*TP+YqZg~(?gwCPRq~D=XFo~xA6*JeJWapPrV% z%|>tWXUbmt5igFjAp7b&mfYdUIObc#%pF3>mUZ(BPu)9^lmv6TZ|fRHyyw&ixAY&QfQ$QOma{<(AF2x z6!0_^LpmInm2 zeeLt3BdM+Msd$YB`?Y;w^4JiA?Mh?AKSB+D#$_87;nybkE(f<9x+!Be{rH^;N-&9n z-oC8-<7!=uViiMXz7r;NxQMPZ^9lv_S3CoC} zPn?0@!xmZRMjuB)KQYFjiP9;F95hdqK+=@Hg4GJ8kc5T;+r-5~_O~IQaPZ=0HC6=f zbRU##u|cC6xS6_#od_7d{85(P|B!s|HP3 zW8oQ@h9pweF(96GjyQ#{!_J~oS$W5m4n5Bx&U$@@8{2inCu)cIR!7|O zl6&MIM8^{2+05o2&ZdX=^tXUiENpOV3t;{l;8zJgOm{;)DRtgRE&AHUG_aD>SIqZY z#vof0>Yo&K6-7Ae~2CqAgkJw!OTHT0b6f|9$-Y<&$oU!FxB^6_}TS9{C2*6 z@5B6(Ta_z(Ke9r6qce5?SI#n43v=!Hm0N%3>!|>L>xoRUWA`Yg)h0gZYYiJ=8Ya(XE(211OA>(k}jd4kJO5I+V{&N z;dy?hj=BoG@1ZR9PSjCDHUG#(6x4ZUmdo$zTOWQ#tEok*lzEi!g$vRP3MDp~eAD=& zDx#e);^+u1c562Z$Z-+nlM^L8-mZ%f?{d|Obj7>UzNiSzeXW9o`SM?!{j>E^sWs>{?p@g8QbBL#*pdDceNQ1*SAZ9Zbo*d3Setm%-$lv1Q^&~yLL*R`la>y_w;2UrV;0CX%MXOFzjAYg}k0FDkP%?~fjdvG`WG>ZW8D?%QPD=3C8B>+bo3_wtJqkt)v#6rE;P6uc@8 zDnh9^Z4@gqJJxLnEnVk60i=Cztt7Y}#)v@@&tfs`FDKb$m%1!ZDIE5Y_J*0~4Ay|u z^&n3yeAm#RsR$yKQ|0ztGK8CF(^})!JnNl* znprdDL;0KzE|Gb0X4fF*7L7y^b*H^rJM7;r+E_F>?1^lb$h{%r$)4b*w0lz#oFi@U zIbN)n%*|1MuOeBrdmt264pgeO2b*0u#cNk^^JNeF^X@?t?OQc5;?l_sM?W&v^I!Hc1sM(2}d{zMdtTEdmc5EdQu$EP&W~BmLzO^Jw$I^mvvS~#) zuwUs+xPc;#e`R4?|9^6<-0c^FOWXrtc0pl^<~zVEo?=~y8pYD-|4S3a;Cq%K8(J?`3`gKHsUM$>!s9|3yVNF zt3b5}QoNij1GD!`@26w~kjQzvI9U8wN>~4$(#5&m{*|9~yzx(cCFlJgoRRaIu0W}P zKlOrHYj0g1&+i>A6Tjcvn@C+B4JnzE7skc~dH!jQ@cfp?x3m7h7z|gzQ}&T6K0xE( znUd#5&)T1th@@~9CPVtA`ESg)b)G;#t9C!#KMo5qvJ%io3!k^=_5j(LrKOW(^bAJ8 z|9WOMBZ$Iw5Tsj2B+&h~-ue3G{lf1L$wW@7>tMC4=TG}qT70*9vACic03xijSm<(} z&MOd6pRDR(5&zqFCveWCKjphc{nu@!aLDl&M~E0Jucx^0Z_iPI;3y5I9-b`R{La zlFQufP68@k1PGIKdP3G|$`{5g@WA9;6#H$@?JeX6U_V##SO5K+<>`cZ_l=$Sp+l-E z)9dVLn$iV&md2!`z{>_*!=1!@Fa4*kZ{pjoZP&J`b8o-PNh@m6$9gIUdPXbWhToxd z`r9e=@i$LRU?3et>->TZvqoLaFdTB(;}2< z(xNt1gXYLjYy=>;WNM_~7}%~=U@`8sb!X*&{&GDBkOB4RbL zkE%o=hw-bRue3@2;$drcS$Lm9m7=45u(c?+oBS*uxBcyxZIi8Uy|mboV6x^xQQoNu z9leSWC_#^+=#QNB6K92G$)kakmKr%c3$4>93KO`|(^zg_GbDuI8PWd zg`h%lGl3{LkegI{8nzfP6ur*2U;J`*AQ&nT=^CQRynec=!gHH!K~PF>BFsj-9B8Pya*!6@rd!=JXpMhmqX-Yq4l$ksN{jC}G%6mSt44RoqX@1U|)= zUJlt6yH65u^U5Z{LrP;Ie#)Yx?JRnu zOl!(|z9Y8;UNyW*Yy~wq3>hNSu!gcNn90&9QT)C&3LjM_tLvrpACkF8n@7tdS~P>o!BZ{pi7tGKvR)XD?~LTo+B2!ZTmxWoc@oi_cEeEN+!%IU>{=4$peo_W3OmROTa zc{MvEnlem^4puD>YZSZ@X9K~z=t=RHb0>2Qz&eZ8!)Mj#mBALzk*r#>T#4oIy1k~dfXf`t*#ceuFSH>&kj9>0ZxN3 z#O>U=+U*#?Z(231g(Be{pCIL7@}!pHB7`am6-~{Ve=7zgUyqx4tV;RmwP?koiKLx)Wd&e1R5TpBxObg ziO1_(m`VUz2+YK_>zsuGzi0GZNBpU!QG2DTx4^%)>$m+FqOeq`k*uAyD~iMyA??C= zuk&~N0pe+2Vz32v>xH+fsGGg}QJixoDYh+>XK zh3%tM(oYa5DSRG4Kj6LNFQrJf*!{q6c^bJL%PNbHA#ucF;_?VL=!|MZn2%bspaafs zZRKJq22JaI5JjHyqJc6XL*)yHfW3nP!6ARV8+hRl3cU_8oMN z>|F*XnwsD&7sHc%&_t`C(${&dzgNd5Yd2l-KQNItAHEeKN$DOjl+}lxe5MY(x-|Pn z@Zgr^hXwDiQ>FW*Zs@WnO${cF+Ftd(nAFgJ6FF0$wrq}}f6KB1(OP42J z8B-q-SYPnVWRRK?E35VoZ3O9s^9R%(@xn0otmiIQs?=WR*PHAcX)<0!UN+SgTFH5N zV}12F_>Y>}hdkI~s@oM%0<=oS~Ce1u6VXNK(cFG@gb+Bunx< zL7bc6cx4F!>a^vhF~L#o+~!{^wu1lBiW5(0aAag`(~CWNh4x4JmK~c$R+#` z*BOJd#|AnTxIKiJ{rH{Ko}?NdHB)n`js)XHd`VF=5i}lGO-9ZLe%p$gibn=pjd;AJ zUs!*%(~TRUV5;JSY>(8xm=z2UvEpU2V2cm!HSxlG%>}Xz7p@|^;W4M%(OU4Jd+?LO z9oEUKF5;DwR!M6ZS7>CFT8#P(LvbNRLL#UMVrjsJUVVol;Uzg;Tvo9`WVqTMfxu+zvcvI)Xe~!U|;Cv{=vGr>CAucVDsv9YV-B^KW!P= z_hyb+wmU?#w=huUa%=<(y$SjG441s0?e*rlPJz%&&tU;%0Bdzk&hLqUK|byKhf*73 znh)2joYa|G1I-TJwKew}YdwGCrrvUQI?w#aW1!ETHcr1)zS8?ZbSN9r`!s1Gc%@zU z?jg@u+%2Z6sH5x{`S8?-kxC(RzfpONtcM0Wh^s}*{7RLD)1~ogr+b`;doBW;L4i%;V^UJlc-`z=E!u7VJKZv2tw#R?p^=$;Zb3^GBtaQ(&5u9|RLwwf` zl5!&HHf|`U^HQC19hpJ2Tv8Mo{U+FZWj1A6(yk(CpVo{2a{ud~A+T zz{Im7Yn5g*GUe%En2mfppL)TU@i6qESb6zD{3c;^{`_`o&ePu7a>0J)i;1PT%4elB zlb5u9_56l3^&cOW;@rnlhQBQjzv=y7jnH*(Ed?&Ta^F^~H+1~3>buTebFV0kF8>AC zbRGVP6h+g?SQ&M?l*oNfv3>b$pu5nPVSJ_}pRaZ)ru?=03Z4Jdr)hz}SyS!Vt!vQgB7=b*&xHlnU)MlZ~G^leToE z>t@c`Pd3VIh!_U&3wnOg=+2YD%@G;aorh=-9da^!L?NPLZvdw>{Sjj*Y!yqb*ad24 zx4;-v0?kQ_mC!#dV#`nZm`oAW19bxjDh-u$hX-_u1k@4r{mK}TAQm;)CoeLb<~9r3 zLw^kWh6fS0*atnO9g)ieK+l0s(&T{u=^S}FB(iV>N+A=_!?z7ocIc;LNzmAH_wO_q zOSY&e934rL847wQ6yppcRj>>rMy&NPqHj~F{8E&N%iw{R*4gbUQVlB z%Y+t%T%E|Q=;x`~P<*C5xAJYQ>Vb`~&Ee9wTw@Iw7XWCVc~F-KlRAs*_GK3^lsVKFox1H>Q{=x1AbOv+7`Dg zBex%nq*5rTYr;CKBB7v55p~4Oo1ZHs%$Oi|zB{pCH?Iuf@vg27^{Pc#7NnIw#2Sb^lchunyf-#rzk)jq*2;D1^~{wy7n# ztP(O%5|$Cuv;DPVwP$7Nd(i4w2DGWqu9nH6<# z-%Xv7GM|3(<0umC_o(?63h?$@7gXe1V5oAjCsvCE`r+uR%e zwh?xI5tN;7EIa|`U3@-NiL4^DZ+4JkR*qBsc4F1;#_t{Iom^lmweo9a8vak=|9{E< zaeIC7x+Id2aY zAVlFua5Gf}b2C@vGCSxK6Z09ZcGR}`qx(C*0J>8A{RKw`hc$xgTxPId_xI@i{eRs2 z$0rvAs-#GsQ~ zTaQM$5Wv-CgxXSyOE-&o2wt>MQk4ly#=512=LaARB^w`nKnnouSzh<6AN9Dw#2wqm zRdIgD!@~b^N^`QZx{hAC&GOW4P36ac9B#{?d0nBpTiOAj2ZGxjy7vJNInk7RxAVpC zcRK^tgKGaH6bbQr^wWG5aOX0_6Apa5CS|-oEL?!k(Ic|b>SbK{L zxDV*`a!#V*B0@LktxI>CB8)3u_xD~H+}Zl%kTToz&fLR~I09M0Qx2M1Q zH%Tprq*^axj?V!Bw|}dPk($B)-vYRqv$mIZy8M#A0TN#)enBu78ou|Mtj+6*wUrTt3=&CZ)Y{}Q|I zqh~yj&DAk?Wk&fnWVmc^Jt}oy4f9S=`~HbyY75%jei-z0t_@?jySXY-4;_SZz~b#* z{F%kP=#VayQ!SfcZ`YF7$E)}#L2JA%XgJ_r8#5_3HYbP)X2!zCCh)}H|HX_%0rVza zjJ=n@M-=<#5<-A^^dPXf0%+1!Y{olZ5zU;7tby~WDAwt#{@S_<@E{(Mp-H7eDIfMy z+rAmCnOD`&)s+NqvvZ0M80&-;kP9fhJd=$*#PUtMrbRj5iZAstdv|72j18*Sd;a%o zv#mhs@&$vNR{Qm>{z@7jHsjB8y7v>}PrhG$aI|v!`91UfrBf4L$vJT{(Vno=yuz4{ zhN~T|Jl)P~{tsd69qC5%j%(trEzabf1A~*#6`d_!LU%h%Xrek7@X7f5D=jU7`_E4L z)n811E=8KR(zyj&%zA7LPF@X8W@78LjPPEpSMqV!>(j*x3YL<6BYy?euTD?3*Trym z@cz>vB@o^BB4`oxv}{kipNu35IiU^W{i2Ge6q>11m2{QWs@SgG zpJ{(&uP+~7DIWV{?JAQN$C(U!b3at$YABj}T-j44E%nrN<5Mumnj%(UE{x6agYyr1y^F562){&bv zedR!_?G9#_(wutd%P4Cv2w`97RFmG1G@KVrH)!;*?V@~uA^ehq*1pI&!v)mBB2l~o=}LsBoL{@BnnO9v9+3QmF#TJ}KY>$NayP7M0O}V4 zw)}0tgTDeEPXXTjVQDY*o;LwXqM5p;i_6NB17Pd7<7d{=o#!%qml*-<0YF$J3*f?d zeN*8(wcw;f-&$wI^IdfW+@>k}2R)FHF$1J<2Q90yrYo0X094+KaR1NU^i<{EELn~( z;Ii)cxpw)ZpHZzvI>cx#co+L-+)3EZNlt^w@2OcGlzn$&mhHzqdyPE#($LxLylx%) z2pH|da62)0bp+BoY{a$`d$^p$;v@TS^ggY5gx=u1YIs6)Xc{tAr(wUG^S+V*=a7LW zsM%ydSZ*ttij*?!yhQ{(KDP&YR&S(N^_S7q?PpPl)P3@(ezOdC8Z>$#1PpTC4jb1W z91ZW3{^K&^=m&Vx?5r3v*{MTE$vl=|2TZO%BM02R-6IE_I=+SrH;bb&HiNr6B;VSF z@3HF}CcW&=ErjowyB`LP?)<7>pc7$a=CTYy>BaCf?>X*-u4B84W|!~Pbu(P?yB;0S)uwst0^Q)-irN^ScXQW@|qDBwJ@Tk`Sld7zmL4W{C2)DMXR$G0A> ztCC7}>$H}T^C|ypNB?oTuADlOCX2V6-1{aMD4g}@t!m0^@Y6biQU+W1ed*iay4UD} z-_!VODk!3I9 z4Mn7m2FkTs$ju`qn-?2^a4HEg8UET(7pj)cyS0l?_a)aQQYqSH(?`x?IEDYOE^9u! zdYqFyHHq7Ife{PVTM|nyKYyO$>ve>%7gHo_}OU7eJ(yAls1gdb%p5V{3`Ut<&`yT zBarP zy0eIhm(>19r-BOW%V%Ms7vE=s)mRRkW*Z>@CdT+EpL)ex7c70zly}Fv#j9gcxM(qk zSZJ(di>9wni?R(Z(N3*%$$@2*h===IXxj*CO<%3=4BHv5s;iIx2wj|wLw+$C%V0e; zQ6f?6v2A=z@U^dZv(+YHlY=*Xk&7>M62dvCQ{tTI!^AuRCq#tOz7j6$W&kxRTQ3MBTn(#2m z(VLs#!7amK8bH$Ya1=&CYiaW9qkB^&+R3pQsXCTRFgCD z50%qnP1I2uQ+wCkXP{&Xe3wRFJ`!+12L>eK(&qVEmj6D0$QNNL#{IGwrCFePY8i?O z9k~cvX=i21)Q(7-wRiobUE{1Qn$W~Thdce()r+QH&aR_IofZ}i@G95c&d3b!-SWp% zMKpvN;lON@Quj_q(2B-maL=Qy`8n=OI|`?Vv>?-@QoB`AMJc;EgBKYln9&&6Ht1gI z`VdNZw(lwYV!3g-vKR|UFxCNl0s1=K0KNcz&A#t|0+0s)Kza9ren$`>ydwzE*=YtK z0|@Vs@qgO~UDiImuO<}D0&t)<>?Qw?H^hJU)p;WTws$F^oMGRMHILxoR}(LYlieol z7Z$IcK;dYmDV#v(EY>%Tz96-_9A^SSNzEF`7Y(x_#*xBsFY|=x%JI>yp4V6C^%vJO z{}cT6m!$6N-|fTBBCSmZ9#C|3uKl&wB#>?(JMNjpxCt*f88qlH^z~`SETe#Jexn!o zGrt#r+N>SQD~UFmF|IT&zX)STZn|!^o5r~d;)`XxbO$l+u0gLqg?T&=1ak|=!|}Vs z^KSB)nn5%{;3I#%kVEd;-(S0G;|BP1X94Ge@P@c632>%s;`0NE8VEv@@(O#-3bRg(pDE>``$}&1Qm71C#spVTwW{Hc@|Qm$069XU zT;J--5{GJM-E?}jIKq!Vq~6jAOZ5T}MQ#oab3JUil~zPVGeMana*+e#ikte>Y&r!8u^QDFe`6M5a!A9@Gc)H1>moi))m#}Fqv?g0N^ z{E10cv*TKu1Prw)kM^gDAzG=@4?K4k*%&b~UtEv-Z-1p*?bfYsl=pqjK#it#11dD`>ky|?5RgCTQd(>(fkfpALCWe4@A-br!&|mKM7K(n%O8@}%?h+tRE#)RF- z7z)xRZQPHoRp&BkSEdRg{F-7~Yi@`n)Svtl?k34DLF~dRf>zu(o$n$!&!s>fBq@WU z__x)pOg9@gQa6IzIwontAMxQp6z(Wzm=9rdQO&6mjnGu7Q56sQYdjie27$bZ@*OuDliFDj<dJc}~eL)B)U<&Ck?m0&|zyGg})t zp$ZjtJf8@PE<<)U)A(*`6b#bbum5C=#bC$&yoxqM692`wqTYsCOhmDm$Q5sKJfP>e zHiX`uG7W6Bnl=L~`?mtMdbq&O>R2q<0fhe#zBM%M^S{i;`p<42eunsLd12nXeuI;9 zmH)JkO!O4Ki`ejE#qhd+=OZ*?iOiQ4Nc_rYskKzfZ&!}`wx3{l@x;>XqRG9ZaUpC| zkvvce=<&D+M535y7%-PpsjHlf!YZtk6Rj&|gPOkRpdTjgF=6FUn^)!VNvP71!(+E% zP)jL%YUfKuDPdB8bn`gVpmiuWA;U^}s#3J}6`h_yv7O%d6?I*yW4$M9>z$@9LhD*6 zVo8gcircI6)x@4;yh^P2Yb_yH;UG3VOSA}v-O-R`Fi`HT%s^lIDRJ7>^2OtaV&f8 z49OSK@2)1y>UF-Dx;&p!4OU6nEdy~Hf-xa9vp&b>rJsmm-e#rQbjBu{GKTb0eq9O< z?XKCLra(;+*XR2HEug8)z8niPuWsBpI^NXe5aqd?Z-?>uxNmE~Lhlo9=mZSXzDkE( ze$+O~%lIZF8}OIZUjphxlTFF|pDf(7+Z*)pz81GGq9yH@-{=>g0CPap<Pe^&Z+7gptfd`psTZHW5UlcA3 zd=v##DrebIvJ%8(!a*o}#}U4Pq9yuOeNC9cnHtt#W`BQ8e7u|rcF!JzDA8!x$>O2sc58MUD@ih@XVHBGLvmPfHTuWX${^s8;CVxR0$8FK58798iu@t#c8kB02jK z#rz4;c{0mOmcR~v4P|_lGEstE4ECoTJc(fX=X;!0f4J;M5)G+~bPpy#%qc_E0}aQ?A?PqJ2yAo#Le538 z^~Zc1=|%JHWUR70v^p9$Teg*4GE_F(DfJMhvR;uX&e`=VnwHIfNCU0=d<*ja9k<9^ z`$8+W@99bEnm#e0qSL1HAk1Pe)_GF|iX|?2Z$~gJ@bHp$B-Pbd)72b( zOUr;e-19uvjVEqvGsMyDA5C}pg+hCsQ=jFtd;a@|&`7wKgOop_;7=>wg`&<)RjR+PNWo#%Ldt7*2c)cG}e_m{ZsAfq2kUlSbToLT2r zgL;$kt$$pOS*Hssw%4&zo5I)ZE6A8c%j?9gPD_v9Ew=0kV7F?KQ*lXWC;u$GGshKM zorH}ilrK+!JI9qtPa&X;71t7o{^l#79KOBiKU)tnH-n`8%;k?cKUwMDN#e%pB09vx z(E5>|gYA@8QBj~|Fx@hr3(w*xpQ?EK4@mGS&Q?G(CwRxymVH;dn)HeGEJLagJ=(Ut(R|RFE|`E$K^nsk|Dw6FucNVKV|5nYQm^2+PW)6HfhcAj zzewIe*2|-h>n6)sciw$O&-HRh9dDTPPg)Z?>Q5~%BHmJT>QMoMYE6QL)eBL0TB5e0 z`Y*&@WF_|EMQV6iK6)oZ0uEpij^+k@%6if4v1Xp}1!e?+ zh>G5{UOhv-pNE_m*f+`aM`T5+bZ@}&9gMpORFX&dq3IMnIdc_($FI9^!uSJ((mr~u)fu>vb)a_$wpf;5_dG|o!& z#Gl0hX}1FxCMB82$Zeh5$!9jS6So)vs56E|&;E)s^BSG6U~mOd={U9M2l;gQmSK<@ zT4;2b*$m6J6-+r%C(KkyTe|baOLUA9&yXZ#R^GWJjt>!h;kU0b0@z`Q7^DpBb*smf ztP>+<>Iuh-7Yw}cq#aK+b7jAzHL@WAJ!ejc?mvA@Olk_+=q(;uCRul~a*m4j;)rNZ z8=LqS_ISC22hmmJTyvIn3-#y%?RxU7MAw#DNwr9HOGV2(HDMe*(JUN`(18k@9MH#M z+zB!+#ezYeZEXQ*Ry%rq4Z`wK8^Y13rVm4$LPNtjkZ`n-Xy&{g@-)59J2%Rq>m*Ra zf9NP>vi9o03hZwlNF<&Hq%nGv&hwXP}Q zH{%UZcWx~vO9NOzXl=#sO`c|TNLRqhPqg{Zd#Ss9Zi?T%SF6cUjUBiN5|OeuYzf-6DQe0nb0>K6UID&I<3l2G~{`wVyNzKa{?% zc0UCEs9nf?sHi>>YaR=joDHyVk0E}oT~Ilh3ixZZsw0PF_NgTzuqM>X((%~R%w z(*~}coCtQe>>k2hoZ&AK_Pz3-Zf@*troUBmv{z^{ii4l(J{6t)gi~#4h_(A(Ct6Co!D*h)0S(y%u1IjJ^c{aN{4vYn z47(IgT19Ic|BDh^pJM^xb`@<53u(k9u6D$C(vMhaZ$=GsdF+%-0Zz6TD~YSMXs3Ko z#5%^3v|J#gj}y~s##V)3zO6{|%QRg@a6;vL5*=eJQq}J)M$}};CMG-vKsR18R<^vS z-PCR+cS9o7c_Dk)sy>6&rZ(@{c~>qvHNKv8L<55U<^+`>yrb9mVS1}hUM&N^Im2AH z&_UE>n^q*P5Saf=TAK_{iQ5EmQtYt&eacWOomtf*&&ri!bE|2?_=ufbJR#zApG-Us zx?0|rz;~lqdC zTK{R3;DQ2EZuc)Jf65*>-tuxg!^BOa5Mv%HwR_5@DQ<%z3ovXitr=L|V>svY7Rh8& zTw3ejy7vuyZY$FTUBLuBL9Is}M>~+9c6vOCkWWV6m20%6<4O6%IEGy`A&byRF*1iJ;6N-O7=CYa3+v}4w-|4x zP&S$bod~Q=(gIOT2WkV}Uo1Nt2N&n7OB%>M;=fG$PD)@k2 z{PR3Wq#n;Rm}?U$Cau>BPsUZ5jIAgL6>gR`Ss*1uOO-~>jRuca^i=*3h7A`}G?WY% z|FaQ915J>q>bZu=wZJir2u6gY>h~DaXw(5nh3m2w^7K;uD@l$zbU}~+tI|I*L)8&R z7lc{!3{k40eKAMqRXDk@54is#rPWObv(f)2=H4sZ7+ zp;m!8py(4OM=@AM^DEv6!s?=s9mVqE^@@+!t!Vw7En1I<<8(xl9W!)Tnj!W%UdzU$ zUef-zmR*;}d$ZWVO986=xzNFDENpl9)6GsS)QDw9{_09O<>_CW?)_DO4lPaL8$xZ6!LgP!=wN4!{=Yimi-QKA<+H=z`TYR) zj4sq=%M3$pZEZHs)s4e&_QA=$*+89Tm(e)Ktw6aKyjo}Y8SC|!9yE%0kEOrF7suis z$u}(>LyX@?T5BJU1wZmuv+(?N^QWp-_h|4Z>?7ZAO#b2 zJ@MAIxV0j_6hv^b-t{Ajv$Q}PVEz3Q(@4|%7+0rnn@v{h97fRn&@Qy^&F;szbTfH9 z^W6We!uk|1eCB?$DRNNqe%JB*dIab?>m->pG~eigRlMkIhaM!gof99ZBN#VtBi%B1 z{y54W_{Yu)I|D6eT#ttp)6u(-VE85bmQJrtkZ}Gg%sf`v>(fd<^!H3kIHw=A)C!#d2lG+ zfUl}qid%V{l4=)3Zl@}yGfzrZamy^Ta#L7uKX5-A-b>=<*`RJ%jBLAuaU!f{}Y$1P`CS) z+|zkcHvkt0bq`Vwp<6kjDJ5S#Gk_N(8cT=`rVRYSC1JYE_pHCR#A7Ps&u>PJ10N#S zdZALKrNNeei81OpEZPquC&#$IOev5D7+_R%ljt%NFf&eJa%;)tR5*uCg**lhxk0#s zTKz~ZZGm&Sjw_s#&h$-~VCI|Hy_<#)3uCwl_&E?6*qF5voaga5Q8fffokEhY{FV*H zpX%d%ozL0KnObsU5tB#M<%Ru|MNN0xiFW7|V(HPQGk>!vNpp&X1%-KLO2Nn`3o0pmC%!Di zfq?|$`@|=^g^mYg6_UuC>&8x(QE;|225Y*MgQ5RcQ%2W;Wc0zh38|IsRe1ND>@B(6 zM!n99Kr{;!e2irNI3&xZcm;wmAII|-6}fXtSR-7vP@j&U2E>0HCnL)(Kbh>nGg}d? z$k66JvZ&w(uN-}sA%vKE`|M-$?orLmly%4Qr@*6dTFKiH!S!1wCdLuvL^6nr6R@BH zrIryD35i(La|UvTCx2P(o+VPIKMr9rxjlL5c~NCqxG%>?+6YVAt%40#)LBkGFrWm~ zJDaBuqC2fSb5Rl=#nW5GG+gYe4}judvhQCwhEUfq@qVQ(fozAQHbi9&&N7;*CVSj z4i|}%i?azJOfaC``Ndto<2C${`0jTVuf&}Qo5ln_i_G?NC{C1R<8ju(5FmdJ+Gsnr zFUzOyu@rsIl$I8HE>zHue2*(N>}v$Nyuw3+-6SlVD1{Tx(@)q)hwgBynPmn7Bnktz zVfxk}CF}j%FYw|R6MGJ%+rs^kHk0ITbKZk0d<|?f2!z=3p0Bx$RKB`Fw8TRcVib;j zeuw^JV>6|G);0OT74B|k8mEGGJ1FV0|2S!5u!fMWxPrLCe)3CMcqa#+ptviKPKRp} z>%_6fnO_`XpCId#kOFd1{Fe*#pvd?~>6MxMSbddiGwcC;076k(0j^BKABF;rLQNP#C@b zlNH+myeNJnd)y)DsZ7WMF7>_LM!ozN?-B12Sl!I8r#)TH z=uO2vDd+F8Eoj^fcrG%j^V?_kP5NVf_gi&^+w}RQ36pU-XY-~!E9R-I&zI=Q_-)@9 zE8OthXNkFE)v=59_lAnd{DIpp$!5C_o2-_-qXy{B3Yk2^!Ob!yE_j9zH9!C|6|PhjTJw;MTyq{{DJs6bSpfCfz#&dTJ7@yWB}8Dk6fpl zs_bp20TVIibp@8({eJMgrL)s#UWUM7qq`$AgRtPXZFcuBd*>mQ%DEMP=SI%Pg`UFt z3w~SYOVMSH>IjsDPgK|b%4aHSy7Pmz(R$WdLl$$fWNc6s$mLO@(Xg4hWHs!|mqK1+F`IV^NfOe)R0zW7A=krC5#hn1402rd z@iIz7k=b@4G4-VLru^-E*y_h7px6|!vL#=Q+A*wAqm??Kb{DvY@-ZuDxV&9bkf6j@ zi`spW$_lS67)4&I_%n9`QWHL>Iu(=H%;llurq>CWN~pN-(MNQyz2&JZq$OngtK zYVNTXt2x`#N{n%nL?Hn5YW*R!9c%5kznoBr7auVD>oaK>tGt{K7C#x)CrN`qx z9FlP{SQmn0DZ`}seHNr;QPS8E&@v9ycly<)q!@3Brly>63b6>IeyB@ms%Mkpd@eyk zoqVJf9MA*1BMAH0kLP0;EWAMU1&>OM@A_1 zB&Y{TVo+{R8?!AIMgL9NgA@folfdz}hn&5N(8JRV&n4m_)T6m7ew8eM6pf$qPm>X# zL}6&)@nB}n77|a}Nmrf4BU7pog%fIzx@Lx^6>=VNN^Wb<*_Wr_o-k%h$EiC4OhVN) zBU#GUO00ph%0QTMM&%xu2KAUAPCmI`AEY!cctHtgz_uYJEs4k6^)forqM@;J;7k%% zB#S-+!;U64%V9E=bj-yT8@%zgdb7<*{0Opo`=1XM${?!?-J#Dl-{kxy;o@@@@XL^Q z266}ii&DwP1tbVIJDCwA5@=g~)bx@-8A8ICFc@r;e{{hz^!V|GYSMCrHu8UvXo(>( zsEC?>K2RGX$aqCE$k{xE!txOKSX(jy)0Q!asHoIMpZxa1!5dS=@X9!;U*i*&Ihi_w zX?P(fl=y6nlFdk%XBr8KMdRpMA6d#Hv5bp`KKxWp>wFenR)P~pN1OXGwLIU-vi zWQRWlM+}imM$-w|r;12!^Tj0NRmsi=Z-J=P|0SwVQ2Hgup5frnebNc)89$(IV5WRB z40~`bq%zT{F~76!9F#JTMdx44Ds}qFEztM#fLkD$(e(IHiEPxQ@zxJPmW$S2w4iF2 zPzkEHTXN;0(4X)i4H;j&CL$eAz3B20(dTc7JX`8$^5oVwr7 z4G-?sqA$+};0ld7-%mRtk9RhFJ^!5fZa&`(1iEfMYan(_82R2(4IfW0Ej?^Rme)NNYRzhUN+dRVGf|MrsT z<=uSWv#4tR^xn7LXL7eG&cL_j(K}ASfUEx$+Q4U7wn6sQ$?j{zvzOQkQUA8}Zz)4X z4u6}c(eGE!J?i{fJzZC(((ZCQN3veI_pucA_TjDbrPnTePg|CLI(Z!P_guz3pZ5g@ z;(fjO8|Z_sdI2pJ(^IznzO8$-(Tg6*N09s2P{8WmZ*R43r!KZKpLoG5u-dL!mHoY2 zg&rTdp(DH%1e<>|MBjU2IXW$3?QNj&MWPY1k!z_=enzskg+P0CF^Iuz7Pkh3YOpJ& zF(gB`?u}$m)ens0CFB;zJUs!KCTPHkf1Q&m51^-`YQ3`?=JH&h5kBXFim)jOOXz9y zuF4^_PDJhI?~e2}I#Q5Xu{_Ajx*LfR-#mH9_W}!VG#uA3wTx3P#4}07AS6c535nus_Xr|=l*_D+ z9yw&STNyFb6t5T8Gol3hWKO=5{Jn3V$0#0#+cwS;&gA@4H@bkX zJHnfn>*szssn(*;-doU`_@-V~4B_hSK2M_+h5g&x<3Zm)Joc{JkNi(ZssRS6is8cN z-Ph-aqfX_}p4qd{+nA~qW0<{x#F3&q>VX*e`dl?|#wrb$@0E_Sel0iZtMT*iY3&ZH zg$e4AJV#c&mXajuwN-WD#<0KNcxGZ+g)+c#?V1G_S_<>J_O>h>n_v1bOEs%yqf-{1 z{(?9|9uPome@UgkR?YcbHZBmJN?`axToJ9*yPZcl>sNG3hvx7z=z5xJFf43ghnF}S zea@W+8g_&hZpxEf$9e>WJ!Ta~jf(~Cx+>#&B#Q0tsLJug`dLDRFs#KeWX){@kHoLymw?Ok$%-D#u|u{@AcprFYChE zbxjCPuNby^O|GGpBaXJce95XTO+-8xm7^KSE#j$@QxFDG1GFSk zm|A99*A{phOZp2k6%2p#aM9?& zhC+?Zg1~;S)K=!ouXnqvVb;&5S5K((G0Wt*0@top-|Ny=R-dcGVRgRpXRmM@%clZK z`l9{BemH6uwnmyKKN_+@bbLGgB~#3jR(*FZT&LdEk~#oAG6>VW46^42GLOwyC6{aL zVP!%zt>P}&j|a!m&!6ojMp?RM?eNH3MlXduQO_Vb6+Ensh2kXq0&;y846+&D2f~iS zqf#_<6yo&Km8T3XTI#h0sgLm7!q`#y(som>TmTzAV8G9&=c&hY_CKuQ2-0c631W_H z)>;BMOBGnKUuq!QU!sMgV#~B5LN&}SjN9Oy(1o z&NR~w#@-0wN3+JHtj;Plr~U9J*B&A@6xYiG5=_4oM64z_v%JRY&P@0&wkX0y+bXGT z$v=!jK`m1R5BxHnrEQg*H+MFk*=SNR&YuS$$V+5*w^g*TigGFxv;pMT#%E>IGX;KJ$HFO-s2D$$D%=d8f`O`H|)nON4jUf<-i z{E>o^K8Z`hz%YTXmwd-<36GxCi7MkOd-v@+S9S>N5=SdoxQ3|_32!v>50aUf#85|T zy~1QzoL+rdoVQ?^{AU9#UdR)&f!bF~$?sz<)RxXfN4jCm)r~I64zi^spaRuZjQY~4 zVWFH4B_V;v#M2oow0W>CW+|CCO$Li)d&el(TdNnBre! z8L%#jrOTBQt5{xICi=iyWp!&wc29NB%7kQ$lS>DmO!MmC&5s zMg<2#VwWh8Ga zNxBmb#Ni5G^%&9)X13rzEF7F7Tu~MNirOl2R?=$HdC6{zaqr1|&g~6p;9IH(P9Ep| zP)kL}1GZbB1D3+=zZ5Z$FiAR(6I0cquRzaHCZaRrT1v?vvFS<;wE#)9879J`u?!l+BkiQ1 zi|POSi6Xy{M?f#(7ly6d82+Vuau+&pdYu{-^y)F5-lWiLT;VBLj1!w(vL!$#*6o#y zV$qdi+1Pi1-tD9gLlX3!hPy;Cpff+B&^B72H+W@?LT{JFxJWv^28e%tB>zg9iS&4uDTceO)_9M3^hXtT)Q?-_pWws)U^NsHSPQ^PuN!xQNU*HxPM?-1p>?9=bAW%%V;ZN%qJ0 zg?6P;8+w1vJ-~(c8`^TCQk-%U-?&$O3q?F5lBJlYUT$^x&7%sH+GmH&Yx4VL=FQco zA3A;6DcdVI*9GsS=Y!-zx18$7Vx;ev6z>C!w|DF3{s~m#kK?L7@K@u04U)ziVowbT zVtYlH{sWxsFJOYcPm3OPZtDl)?~jQ@yT95;Je+2PHy>x#5NPki`MXVlQfpj)!H(7^ z@a%rTq`u(FEs1lhoJ8B-yL!CwlQ$M$e<~o*l`A3)>e{dF>eQm6Y@TDBFu#z_gqBgbCg=H_|R`)_ey%5H{4MHBlp0{Pb0 zu~E{;T!L;d506{u=WqY_d*^e!8p_*lwXsW1&gS8{vCG<6UwE@k2G)7NLr?qHCUS73RbN8YJLFIxZY**MNMWnEZ9$F+grb(I zqg<@><0(ucT4V0LmL@dOR&;n;52!QkGJEzY^U|0w z)YxvsJc`On>oUrr4=CA(4W#V4Gz>Hc?t}m(>P1;Fu3KYiy=h5z4Q9ZKN?c z*>-uH*pKWA#cNbWg;kRCIg5M4)%HbxBMF?PiN_ou>4e3@Dvn~E|CSNkgc=;ITkSrY z+6>=$&tbvJ;U=)Lr~y=)M~{HK)sa8Auve(5$wM{geFGGD<;+beK)4 z6431i*cqzj4sZNVpm+_&#sZh{j0nIvjmIO2P-z1V|KRaAJxSvxm8Wk5mwQFsN6;x7;Qcg2ZzB$hpqX9 znrH-ZV(#NB7fX;O$m4i#??oPZsndYwYnp`YW8uEhCos%XsYmg)?f5lwR9GivN-j7v zCB;T5pvey`vG9ReY4P)Ik}j~&jdZ24NI65$zbo^gnQVi`T_z6WnepT!u?aO2l10cU z%`9ioh>+3dt-LFfR1vg>Me%~&sPL)Dy9+11uztsD!qVDkWrzG1wqA_eU?@n3YjN1f zt5e}ZqOf8GD?ySh40>PWDH?4q1o2HdNnz~>L!@V!&#Igw%GH3<-;;y+KpG`Ub6!@; za{!5E)?&%1!bB7VR@=on!X}gaOd7-xK^g{;cIwHW5D_vt_R1|7OSEjNXwFXv49xKu zA;P8j+pg#aMVa78wf5;k=-E>#M{1&;-{hhh81$s40W_$fE1KKLwaeH@WRNR$DK|pV zSV^{8pjey%EoZ22z6?7kQ=gKxQYQTMqYTD31;SDk-vQ8oYnDMtNY$ZehU z1b05Z>FmX{8NBTEy&dzt7mUrzQuGLlJKI1))+SVDh40szX2qV@p|&+O`Qw<|wYf>a z`~9m6}QctW6vIWGGK`TdG@LAB8>)9Vd3PHRJFdYIk)Ttwis`FFg~ze)m? zcgZhi>CK||YiCPpjzoQD!lQL^e-fa&rR4L+M+@DZrR+J=gq&0yRRpyhE>0H zYE4mTc9wr{+wd3L>f~DB`)k`~fNy%h)9?JZN#i$c*9pc7zvI=-U{cpMsZS@lO|zy2 z=Fi9|cfSkX+-qLEymkT3M1SjQac9}d+f@UuQdE6@rSx5d%{I;5L6hXA&v(Hl5ftA9 z9$wD_-$QX;Ffhrh=*5oY8AN_M`mU}@)?1Qe7;!!|1sk|;htW80bQ5ima7L0e#;|oq z_V^!$Q26ZN6x{q>>WN>oGd|Y)FZBSnuzI^~+_HHBk7JCV%vM|1u$S6Lcr!83o%#;) zQ-vcG5AntC;{BAWvRq$xt_^H_V+r(#R=L)t-&6Aw>$GGAk@t5tReKN0C*;ymo zS<(5xOVqv<0*w>Yf@gc6PRGB`s2}_8{;(GWzCAAW4Fo7Rxt6>-xkBxE|2yvY_<-?~ z8w$U^?$vV%Oz)wej;93u6(X0FG3>qVwIkU7trcB~`$W#WQP~6vgxLKqC%uLy@3Wy! z4o2B>vMKF@YqYYnW^*^L*VwxvSvOh>XLS8n(o5BA=eOaWGW|=rgUu6(<4eG$R!2@Yb1C&9bNRsM&f|}5xq^|C?*Tl#_Yj_GjJ(7YhTh!ok zeuQM`O;VcXe$rvG(-O(+=L#bo3|T<0mFKl0qf#47!1QeaZpkRpOf>JJt+Sc^lZi}B zCN>n2;Q$YOx5N^Qm98iBGZ=CwQlTo6%1zNP#TMFEtpIW)e#!w1I&G%ndHWajc55xG)-tu2ynmmNwz2|FTW_NO}A{jJ-cKiiE@?cZbox zyZleK?`DdU@6SCXm(sduL0%3Wnl&_v8k4Mp=9ojTj6)6Ghzs!P=y?4Hvit)@lwAXw zw0hvT&$^8Kpik-((^4q#+lgjgT@KcM!;GfZOy(5S>=SJNd}B2D0>(@kI zkESVIEJurdn&C4LoqSGog|Wb3Dw$6c`Q<9rt+=u?9?6P=fa?vWxBqLcshxKaoB zzO%Ez7&WAZO6q2Fe)sPNZMxEtL=-cWCpBM_QP6w>gP~j4^quq%-Fdx!p`~d$_4ruO z*?zrS7L)i~>)etx@x0bt$Q(2yC^vlykXM&CpXb5VwKrPMep=EiFQ6u*HG?x4n0 z8}mI_l$i<3rVJ(lc{a&y9Q%?3ZxY1FV1O!Py2ZH(N4vqnxQGDA!JLUmh9~&I-4#Ux zQw)(6AgQ3kILNT$%YG%sBZM{#D>gNMQxE>atR%cEcj0^P(63wSEcees|Du;MaUs|K znop2W3}5@;&JC|T@9@*tQoN0#S7;;rFpaQ`9|Ko#G<2g<&Ixy%^z(9#jnQo|c0YAT8SiurxNhHlYW5S|cj6!cCr48k$pp|^Bn_ax!|7PD9R>ML$uqDpP3n;lY zxwps(WyFp%Exn*rm!<1*CiF>&||doiAFzOTPO_$T&7+>W~o!$3U1X!=pR=Ro1uW)aE z>v!~AanQNoY8bq35V)Q)?mnOi%wA}z^LOCY>$;K^y8@h{nB8soI$dzJP2O=~3SE5q z=5;xo)AtrPt}i5Z8Kp{j*86;RY{X*P$Ng=mYg4m!|0%HD38(|jBY^Mu>=;-l&A(N; zI5x>WGlVy@7{9HEk>Yk++%4{Ic6wjtL_KHQt>x~Y4nuDrae}1&%v-hZ?I!A-d)%YW zcYXcaYMfo*>Sc;5uDQ?>+khRsN4O{1PThjoZI-HLpogW0r6v3obK}+B&iez@5P|kL zY#4HII=DBNVC(6*X*XwV?CdHR;4#(g_T`w%zhy(Od<4 zpzIrdFe?#gZ-0rn7l*m4Vf$(wFAk=5C=wrRJOw>tSH7|csdFu-FpBCW?`I|@lca+BdFMlL)gj|tE$O<)m1Tg^wgRz5cBq&IH7;OHjb56!2Gk7PKC!wLDY2v4S@rqdY zQC5psEtk~vJp!P#dDJ71(tf)KA~)g>mZw6B2tcaG9mqtCojcf}xUnYxX-OK?)92Ji zQB{(6^a`VClZ!+`jRJ`3urr}hm{aQ}_s0uM&d9`Eu;4o52eHZuTH%ok(l8~t zLoM}Dd|GB)N0SCeut{L-x`4HpdhA&b_nOwPP80>khDCZ(f)T&i?41F7Sdh(lfrMeP zkU_`@9_o@)^NbfeIs0@#uK0RJM=g@8kU6C#2EsE+c8{CarFNzZ=TN0tvD-nS8g4{; zlK}@GS0{ba11f*7j_d_`UZ4pcYn~*dABI&@u`EHQNo{f|n6;9D(I!Yag)@rRlV-q{ zEm2wmhe5TX-6mir##$H`4S~4`O^L;iH5DdC8Yw;38xCh=2#muT_G{9lPO@3c;zk-J z>o+WUba0W@|H!QIoN%RyrJ|A;;2o4xBLS??JeNmm0Ssd4{pjQ3zA~SgO8)(mss(P3 za~OEKaxcmthLR0{CyQdsM)jA5TEZ4t8BR~<#|4riKolX45`v{U|FE&z1`b|sO zN#OMGS0VGFs5KvYQA3Y^NtA$*Y=)y#^AM5sBJLL<1XG)mTay2X+Cu!FUI1#+g;^XT z(PwixHp*!UyJA7mgUmHvdkhu%`g91L&k$Bg^It$RD>gHm9$cYqGcF1#XP8C_VNQ>8 zQ79E54$(Fq*Pw8iW(wLVE&{yqq9hMjL?j}a)@!!?MkJY)q%?z#0?XQ1Gj|m=ZS;x= zj=aWz+(r>EEn?^>Y&9ZAD^U3o3n(Sk%D_nF9g+VGiLyzID+&tv##pW{(S&=HX|fVY z<6e-kaLOdDrdJ5B%s`fblAuNthmb5hkNcNV56(q_kot!OFF-@dQ4ONv^OPbD ze@L3cp-w^qS)!Hz_b9CrQHoGPoTWZVNNo>{{VR&36E&fBlhaZjkNbrdjjv@xgH21F ztF9q;!-=rwmT(qL4jkU&PFluO<5-TxjV`?Zf?HySQCy{HzXc%Hx35v}n*I4W@%Le+$XrO8A5nDmQ(E3YxV!xmH$#gGhSE1@Ey^0(g*sw`OT@oLZ^ulaiyRCOqw z$%>$ZkS(kyRD2}1ge0MZ$1j94=%qsXJwl|Hn7OECK%G zn^X9lm!NzzTrKr~eDnXy6ZW~7Mv#3o!K`>)3jE#!tw{qy)-fK540{AiyBUuBB z#?@BRp!l({QrmxASlZ@IJCsfv4B%gON)WvZG9Z42wDo!qz!RKYBR*HoL#3(9`&!2M zC3!QCqYXlKCeYI)yk=T=N$-^Qu$vILnm&&ozwd||&|r)FoHtMxu+O}&7Y{A=peeE4 zc1)sqRl>OyA}I&|6<=E~?-N1aL)m|IY{|S$nQq7QPp6Q^@b!lgeztS*hr>}ZKAcwD z-{n5@;I>W2D^n5fOSg)QQ_J+6yPBq%+NGzf2bxLYsjo}t9O7G6{LDH!Vl=iDd7jA+ z=JtNK)yAwo0|f_&vZuWfo4dh{MG+wn#z`$DcYP|taXW(%Xbo*L{0DE)h`qBjil84e z@L{lEJOSJ}Jngh${ijsjZOianPv1L!fwPa>ufYF~ap`K~zOOEkw?20;+=#i7&uyvb z<;N-Lc|DPKM(p6^s+HBtDbTCuIE|o;yw0=d_;zC-d9$xJt03qBYK?qkXt!`C2XM+z}v|bBb^nY&cRAd6LKe1^yF^RLJA7;Sjn%5@%Ne%+a4;k5F9CP zvQliCglTRvKw;146tc}GplwhjApoM&0=`T_k1wT*=_mUem=61AWQ}Lg6%hfEn`%aO z^Sk<44(y0oy@}0pP5c8XK`dmqph8j&KB_KS%MHzoM@?5EbWISjZpX_{ z@DrXfa{xLIq4ebCN?rsFLrvgEvUW16E})R2#~kCrAZLG4!Nn<7M}g0z;>6eeubG z8mf|Hn@j4_3^8{%Y1h#0kkW3*^w|dK&}>jR;p_|R9Gz@e`xZ*-<~!a! zSRAaChifhQ%87qYsLp7byBZSWal74CK)FHgt0n6_+a<>FI=#IN;q892<_55Fzy9P~5R$JM00=c+h zmcDi?pO3?nrQ%Pr4WxrF!PV7XQ!fwyxC(}(!Z9wY~dbLz@Q*kb)Bh$*jMk_UC>2GU?V$)>c00Swi z3-L3+<;RYI=oya2m`F1WlG3Cp313AgbDPtP=y@w_;ECXggnG*$j7oN}StMFoC3UA$ zm9(DLE0@ux8F6^dY0D|25CdYoF=^_dyD(VsuGJOWQpoaeb2}5a>|fh)ID$Rp*}%8< zLkyo5vG_Fx8=#?YgA;X+1-c70FR5^i8tyaSFK9UYzxzwtabZrS;pWggJzbTffX3LM zuL7+W=<~gCH~)p{|Cg6ue9YQb77N5Zp~WM1Amsh*+XL)YGe&b#mWalY@lKX2oN zEWdE2S4}Xsr;xwe4w3WDnSm5q~^Vr4_wvNMM~P8<-6^Yl5O$DFqLuf71TE zKP0f%ssFo_ef`|S`QU%PyD79(Vc@kL_J02EpLqmJ@$liob0gqkkRnYLr~9s@)&oj# z`#`_0rb-f^AlrIH(zL-F6nh$ry1nar-&3`M$#jHnFWo;?IQM;jehit13Z>Lg*ZQf6 z^N4oi|3K~h`eEOm>Iz~#d5HbDLMVb3Km!58`7awDwVIPdQA=YHO`)X{_6MTPh?QEQuLKsIh^-~kG$@O#GFR%M>& zU!r)2!)?kp9c$}6Z~S4;jgD2q?k^iT%yyf(p5ixZ!GTvP1@*Wr2OZ5!(NvP;c~_IpAr z%pLq$9|-sT1g7NkP~Yon{afCdd&QeaRpO}lTV1&6F;m-n$*Nny1@S{v;PcRd*u5Y| z;&}ohB*L;br%S0Hp5P&a#n|Ho&5&-tjV8Q+C;mbFt!$7Ux}$KAzcSCiwE#@IKIN#B zjj*3_kcVh?aG-zOx?-j8<)qUN#zPUm&#m%Q4BVeIW{Uc+%B2_Qe9k{lZWJN+Sr!dE z7g}PyG#G=X(RvScFteiP1{025)2*sL6Wm_9&reIet`qX-8!i=RGF9*B9g5-V|E}zm z9|G0+iA_R-544ZW(VFFWlfTb11*^z9dCB$>eEC>_ z8>dY&cnR`axtb6#p5+l|l~F z4^t3}LBWmFr;#vIf?I4HyCIi~wh6b<1$-HDT^6YAg%u`u^vL6C+ac%vGYOVLBfeDB zH~y%xI_0W`zJ{j72T_Bz+Se!40wxt5vJ_MIBuhsPm)QgR-jqt*WN4@>GcUAK2~9;G zFEUvp9#CBW$VzgA^%pT7xPh3XT`Vrf%@131=%jJlzs-JLi@ikJjQ6Wfj?y!QUW zlRHW5LMuD&qgC50qGIhka?21j2T}n1Ogx;w1-B$o!6h_V%R*6^sr$bQ<&j*H5IwlJ z87d)38hbH>B?2DbgNvP?iS1^z6$c-iQl%u_bt?f?la{Q1QGL z^^XDqvHs-wv3R-=9BQm!x{rXU@if06S3NY2U5nIyvs7ja3~aoBp_0_-T6?%jCMtE4 zxrN(G2pIwE;ja(WEdOotWZWf1<@Gu+Ew{d{!mw>$E#2t8OsgGn4adU~t znI(H!JaI+}0AO!9=Tmup*ia}L&p^JGSHiFzw6Q8mguMJ`y*=pL1mwJnFsa3c8O5Jh z{llYFI+Ptzx@AYMm$D}iUN5PCr zs>F~v{+1|1xAr*_9R|)?DHbU#6=_VQ!VH2edJwBk=*lGcDcj_7=UlF|dRt9^LM{6O z!C3kR=|~B`im6*8;Uig2sgDAsCQW_Q1q*4orbZkKt8FAOZhD)H|1(?@5si=^7!XGT zgTx7PRSPbfk_qbp6-4p$C*pDvgruu<15mD|_XAR&vnYYdLa8J~Xj=v;iL@LH*s;Ya zm_OCVE1=joyp*!ErkaNI8A`4)^(el2QK>WRz6c(TzJ|oOiY>;EW)tTO6d&$iEa5%# z)Ns_aWk{;fzs4~n0G^TC|MUWE5wfDKB2tk-9DQ)ar(b~PAWdLzR(>)5d=$$Mpwy_K5N~N#T2S-=YIFXJ#F-LPtSc3 zn=e7Iwe`F4iHhc<2j^5TCE`!i(cQ0P=N|}GZhA$Y@7nrAabBQ?s2>mUY2##VThFQG z9m$hb`oqs`u`BfiAtg#a(yNkodSO6oO$*=pLYMAIWTq};Eosb zc_`0qbr?le8R<~GPdiASyx{VtsvmkJF!*q%Ee4J|a_l-sc*9uD*?0gMA*u*ImX;K} z#X4MjV&wb$tJ?qG_3MEjW43$XtT={k=TqydqkjGJ6{c*|JKi??j7ZAFzW+$#MtX;! zlY0pX;5}>hAur7M`R@{2uNU|4!LtBQXPb(Gw~)WRZ*lLWBQK5&zGofu1g@8mn1L3D z$0d}1P(TayT&TMbW%tj~UW$qjWL|btL_9MQ@yT-ZF+|6Dai{BGFN|?TXN1B^5U1lV z^*7~~toW0uH&foXzxP=i`)r;cV;uU{zug-)y$(Dy!79F>r8^yWo!&9~c`i*wXZ*Rb z418Br{+;W;s5|TTWQ=L|_8|M_+@6N_PNV6o!~YnFbPPkooO^&(5!gd+maAoZ%g=_DG%hBwJ+<@k-V@>Qq z7J5V8%S()Tuwom+In$!`OJ)gM zd@RE;U6Gq1MYzToc~YMBnxnJm6oI-f@};1H9L8F;)LfX*ngqBcnKd*Yw+$L{$wk@V z5k~U$Jfm-6P1Qm1-ez^TT<|eK2_%kmQA>*omf1XU_H0Sp;eu3NBIbcEI(|I2yN`7Z zceT^{0c_45|8RE&zaV|l0H(H`2{w^p&v}2&l$<>xnz2#Dh@GzLtz@y}$XdH(S05V7eb4(-Rw1yI z2DIp~pUqP?(c312^k;inxja2*^k~udj!qr9e~UWf1(=vyQ0FTPj$N<8nhMgW<+!kQ zB4A*uco26-Tag*FIVGU%kSZCDS$=95jAHK0B&xjyn^`Y0h;GPG z@&@%|mI1hYzSneC6mC=zU1QYBo77%)`TN|Z``|N~mpH~ilHO<@h_K1^W9Wb;Zzj%J z=WD891N?O^W0p#=Iu3GX>_b+Fv|tc#=JPE787rptv6bL@Xv3C>k~czSj5lv`m3}>Q zdJ3mCz}0%5ai!`Pkz$+3(_V-Dj6tR4A_+_`T*;&m@djq;=QRKZXksc`I9W;YLCEfL zurOrOy2t}DO}J3YIus0{GUNQZ{Pbjr_k37$^6364y}7Ao5jcuKqJK6a%afzfVOG0C zMfBl@l%Jpb`3MUU;{46r?Nio_*$AHJsw)lKWV6Sy03+LU;s72!Rtpk$t}vEA3ZwiD z%#@}x2s^rrwjP}n#~pww2>C;M4UwxA#6}r$`@t_QAm=i%Ft(6R8>DMgfVo=aNTpv$ zd>NJEGdzA<9D-4w@`E%+_YohVk)nQ~gg6QW!-*)AmS0@P>G_8x{;(9Br54>C3^zE= zs~3!IHUuwa4!bu~*n=uG;1#wUN;|szo|N>?V?j@ z8?UwIpQOR=(3~E9ajGKoA6@$p(07(IczTNv8Kh0U2&pfFP%J#X-10=DGXiWsslgL2KS7>E~BddzY4L}qoYb_g1ukYiqEH-&oO~?rnhdzV z4)jtL+_!Ls(oE2xwJYJmR4-OPvZbHvbpxu3N3&BKitSzkPk6qrPY<3OfcjF0-V?U# z7u46qPKOF{G}{QWR(4^*^R+YRvFbOTnBMSP9)*73eW+MosMdK;H;j_$uw6slVY{T5hv4p99D=(CcPF?LEChECesOnq2=4Cg zZo!@4?$Sfs|Dj{FFKtIhA9!JK&)o+&cdxzHoZm!#E~z27A}tL(gW9eKiUd&QSH4y* zKq+35m^h}qw%i7}-?IKh*yY^#LePoR0$9e7+|Gr6@*EcBnizVS7IbhP$&?!#JKlbg z2eZ^aTR;W_Vr3U^jQ%eQYpjyCgk4A;=YT?3-b%mMu)=fo=y4%m2=Hi&_SKO*_dNGi zX96;}#8P>W=k2H??ftt>L$$WQYe-&`e)E;?>-%fucJx+i{AIV7w}%eBpNZ_kE$&%R zf;s@~(cZtpw-4PDWV_CT-Bsz%Yso}(#J~o7x2!mEzci6_+B!1(1Yhy=v|7RP+I1)2 zqqS_G;dyOo<&|s7do%4dy>q2|9|O!%oq*YGB+sSdgh${yvCGV7tB5%ZOYoU<58nFW zd_$j=!*(hHns*!#PD^!lw@>TkS;Wf&PW$1}%jQk+*A5n}|BPbU@O@iAQ&$wvZdarNi>3c>k-1Aq5Z2q%R?YW8Xj|631U#xP=&`+1Xn?bLr9540#?!t$@UO1 z?)Oh4lcc@UVoATHnEN}igfiHr9q{>texZorv($f+)tI45WTYBCWGg^Nf#axD=cK?i zYD76C!FD(LB+hPvLISCSA_f&e5F9m}h0;eogrHKdzzDVI*h-)*Qq8m{agI-0PW?Vj z9PB)f5{Upzv{~vRD5&t6{lwHD@ZKJ^v!Y-DNxLtFn*(n(2Dy1Yt+qG{3+56s&OT_X z_QnzJUuB-WxLaAy1jv@8VKBQJCeFrQ#$P6P9~t3`Of2ppgicC74E)TX%r!B)q3`^x zi&c$Pgu7A||#a9r~8Kn|t`Or;T>!UYjp`Kb#; zLvP@O1r0g{@vDuLgikNB@b-NcmilEeF z8WGf?EZ%sO0T;S~XF9dqm#NQ8|Mf@Go4^tubGxw(zIs5}QZ;jb42`g`9oAL@Gwi!K z5ZOmMQLoTk3rSE}`cs|~(AwKPuP$)|^HV>HIsA{-{yDL+Nuef+A^Pa6V&c(S!+=3l z+qT1s*f}H_F7wOr&KAC*GTfLUgyTVQSPm9_S29jMp{T{zD=G945lO01nq%~ADYP&q z(_>e#h|eiuFE6v9PTWqC2Ewcj8nKC>ng}vB6ILI`S2nnIn4dyY1&pbF!wKU*jHsj% z;|(Ej`+KqBU}06x^ScY9!p-DDZG=Q}OcaY{3|!{@8Dx_ov2yH~djT&k8Uk*J8Hog$ z(6k*U3S7$U7rw+&z_)v_Nm7`-0qR1QQT|e#D*hM<=E=dyvI{0ARQ`9^&zvf{pkN*? zY#23@EW$@%yJx2qR6^c1XKqMk90;VLl>`h_nu;1JZXYKe|v z5A!F2P_b-^SwQ3~3K$26!i&yvRLnVv(NChdd@?%YmaY_VTVO)X29w1!NT|?5ff;Qo zHXCx2E65Q$ z8rR<>W1>^Xu~ftu2u`SzP;?$5a7Nd%d`A)}VM;kO!r|x+MiNH!924Y5*PLUYoENba z(<5j7&9Vj*04P>J7?!yYxigrncE~)Io|KIJZ=Qa!M(CG@GRJxTYQ33<(zf8?7Ds_O zgVAgN;xV4BG7V^$5seU6-T3Vh^JD5W{INK4x_1|(_eG*N;Iew-JGRXe?_T{BY8H z1sG%LtUZsm0e$df3(@U`{CyjN`*GF4%_ScZ-rw`L=G(g9b&HBN-Q<&h^4?I&$ADW1 zT7CH)tufENGbtSDzBIyVIqx4UA`e#X&yi8_|X-Cpa!W>DfiCGRV&YhlOb&j;F?yEXT-`1qT3Emb>j z%8RGB-8-7H@_V{?3%!lY3XECTXEe+;N0&raT~kcadeYYutKsAdIcM)lKO&F6=7qLg z@_>-odDU^D7(8NC4PZm8{_a@&Hgvq4jGU;fc@8#1uN<2gr?Yt5?s>oNbIeyVcx;9O z`&+`D-|NnRK*rI_3A*$8c1R>JLOMR#xobU=)_dmtVV3#aI}qY>-$rr9zE zCJ}-32xzFzl$ZA&vX--I>P#K}VV`TnH6my4vW;#Ol4+@y@{lf$$7n2Jqy4yl_S-dC zPeW0z&Twk6G^`5t%9nOXG+kY>lo<0@49CB+s@?@4|4Oa$u`@1YQF10Mx-^gvjRCVy z6|Z8^Xl5gYX+dK%j*3~Zy7n)1fV_d8DLFhv0tOmaEQKoSNb>B8R8x6B&(KPdO}($Z zTK6IXa=L5?wopg3bmS)~XopOC6yK6S_> zylL#->b9I!Df-%iIkN@58`qW}T@x-7c|(7x@Q3+6*6+`t42;Hn(chXzpF=>{j+Jx$ zcz*f;NVa_?(A4Jb&1B`m^SFB~z(>ZF?a*~n_eHVN*afXY+K^6gd_3_8ldGB6Ah_5S z?e?~D%e=5?TV`g~h9Ay|#ZP3btWskTuN=%h<`D4Ot2(=(iCV%v_tG;x6$e-P{ZE;Q z@;w43-%+D)W5u(}1B!nv51Jk=?rdH}wQqMJgq;qtSURqBx+Zn&%yOW)szuHgc$=Fx zz|>9M`X-ZT8eZ<#x%0XVyaleljaF#Y4xNz$`$4IW+|9FQnc98bdDiDak zsQi_hS*LA)Mn8XfTUxq9c)#>39%FNjl1)obbS6QWdH-UIke+G8Xl4)Ny99wM(Xq#) zY4;F+XuG-kK*-|NPau}Yv8*%HH3%J+HsGllG%>$FLwvdDW2CkFmkX!z_{%sywEAtV zBWpl;?n7ol78tKH2McQ%SokV4UoS{wrFXbgq=4mYZ z`}-K{qo9W6V818FP~E7^(T8MS*ZIw*s;#YuqB7qbIdB-pt?GE}Tx`woALZJRZXwrt zDq0!$2)WV2Yjb4A+_)e$bNvrpHAssv4Ns5P(|-qjmv$>p`Q1UdF6 zFJ2btvI%jf3QW!pdfyX|r9 zr`aDx#bNPkMb{Jd#< zX@-a6M=PoH0W&tvkttjD=U9-L#eM(^XKa%Rp+(A(_Ecp#=0I9JECfQ&KHUu+sw7Ei>zSk-7(R4(Kq zWm9n(8YEK>(K2it4AqUpO11~`OLeyk25VAQCT6uYc9|7x;g(p>@Kt;8u(#lXcV+q; zL$=^_joB#F=XEd~>x-jz*{kd`5qLCob~R(3JaDHyPr&|)WdcDv%d-+MvSaMAECvFz z2A{Loxs-4O#0DZ=Pv{;~-en2Parezao1#Q%NbVcB4MVgkLi9FhPl}%W%US4GUaV&x zhBNGA?7B=S;cg|ievUZG z9=ag??G0(CzfJI{>&~e-|6r>VOfU2;s!wS7*UYaLNwk%ME#Y+SUfdiHS|XWwRjYz4EWg52OaOb`Iorv0(+Gh3os z)A0A1h3}1r-)}=EA+fY!0<{XQo9?&Xc^~iXPOs;+XA@Ll^|+dcsJ zkgB-^4!MK&cK|_BcYpGIj|QBce7Y{9Oy{-la7l{r&3C6OR>uwpE$^dZq?~0;>VR)Q z(ow3RNB&Qk&YWl5_N1BPa9fYYF}0ANZ&};8nx)Dtn5;CL$&mxC2jusl+B2e85^RgQ zVz`FZIXX4mjw+LFkMx4zRdp)g44@cCSXJiP+@#)lYDsu*=#H z@T^ufzM5`1w?D1gZF02zlZ8(XebB7(wE*sr78-`R&&sKW>;0gd_to6;UdJKzvh0_q zR4$Y*e*!AAQDLUnqY>fLVkpDfDCrZxShnqV*N{cxIj&Ut3CPnn@GEK0noJcD`K~90 zfM!P6!0SV_1vWz-xg9u;PR;W#(sQb5(8rGS;St@7!}$34nsHwHQ-&ZqcYZ*c9p_v| zaM2lb+*+d->%3?X6vV&9}+vi<60mvklLE$1{=uG(~TDSGO z13IBDu>Z@IFV;gY-`i7%_v4}3s}8KomL9=U{YiPt>FpwEB+amKD1P6>>r+Q@(}~kE zdw8Z^Lq|_~xvOkD`~aTrUF1Pu0s?JUqVlg4v1ff%QhW3%UwU#e!_BN%vTRLfPK-1d z%dY{RpU6?jeMrgz8ifT>awXu%13!V7zI~h0?}MvgTKsGeoe%|A{MW^sDU?YSJ*0Fp z8{Mu%(X5xM`)4n*sB|*6Sy#VIIKFN!Gjq3FuTU?_rtzGNfhwI`ZcYzC9)etC&6z7o zW5vlV@>PN`-Xlhp42SrX)il!#5h_W5^XVfp6ec0c5FQhEB9rUyTk7r%Fd|>`#N2l^ zs@7Rsu_z@i<=_DMC~YT6dd^KjTBbppNk|+J>NkH8j9=LO^Y%GYVD+%*HCQ*?QW86i zLzF*J0H+F~Mk+eV8J6!x_p5Zs4yrP6+oDw8nlPpfvbI%*E@f1QNqnffgbNQC+bnFJ z@FWGxzfE=X`I4BSRK#2JZeahFU zB{UXTl`A+J4NY8@VOvmCD{S0fP~0F-B^L%ZGBev*IlKnbrBr0B>b#0_?ejd^#duAK!sT`Zc(cEGm+=qd2USCHPU za5zY_OaiXarsRYAr@p9alga}PaX_ccrV|W~8q|chmHu`Qji?y311FpzK1=nt*0AD} zM(N@YDJjNC2Ob>!tqi=nezGPQSwf|}7=5LD<^6gsJZ?p=cz5+L+X z`52!zU>glC_H?>?Zi8wVQ>UCVdYO?GDldz}-#;wPo-|z1MqY^3kZd5VNhvvU%?}j) zX&ku@^q~)ZOIf6Ai*7|pL=~I(A{?KXahCO*N(xZ-Gp}7>u!Y;;@(t4c4&?qxtd0m9 zt?nQ$8J+w`kzAsd9;yJGh7Br+k`!tDBoG;`m21QP(F_x|Hg-CjdOTq0$sAph&s@kV zx(AyN=W1L!;;U$s#aK&B$*u?-6Rac-*PcduvVI4>5ABLeNP!P->W-|4-v%=mnTVlK zHo@Mva+YRpbaHyiFcie#BtgtB^HI7mF5{$$EdQ(ng+1=cWr4lPX z7)}+pKhfio^&LW#&Z3w_d}w9;yz~D}8hwUglE^|3)ZYxbmd(a3o{3-cHQ*B>`~b}) zp&?;qK$RG=sG$n>rTwBYL^`T1n;jC=WC*8&VVqBruVIc_W_w^$tjKu6=)2@E{UrqX zlg{~Qq(&Tu&?LN6wwRPB)BtzYmKh!)8`MzjdDJAfahQlK$=vt2{*tKSTH$PeK@eRA zP%+%1LxD=wI)B+19aKjb!D59@0+Pn7@klz}>|svp4q@tvK81ZdjX$kvBhjkchR9 z8qJ5b;v*U=R2&y%PNY!3zD)aLdvqmzNqr&wcUpC8p{ZbMAR}o~^s&GmPL9(2cdi_0 zg}$ECxitPm=m;3p*p~mwvTk%o`xoZ|AY!yenLEh*+Bh`yvs8Q$H3sBJkFJ2JmE7C8 zvNzKh6GQN9%K&9BXl?8alIw2eD&vO3`>M3V)u{`Z8C$wIFS#c2-5&67)0w2|UJR@K zT5HvqzE?AC*8DN%3hd#3HEPycN9p1Wbtwux9j%!6pZ{zCT?87$Y ze|iD+4L%uwEma$a%rc$ZFA06GGl$z=$a`DqA*op_Y~8!x%W26t@e@tmq#?V+vRjJFOoEn5OC zb@nyC)5FL<-{*2MxA$YAMOtUJGY`ZW;OW&O(r;JoO{u^j)3KxNfKS4! zrmvFq@;Y34sEVswyMkwEJ@-dj8@d+bXq}3``wMTEy!`sKo|FOru{jQB?tvP{w-KPG z`Xo*hUYrLhXIm~S%m4GrWtv&HYdQRWx3-V;8YO#LeN>+Le4Hq7^H4Fq=(O6f=Q0$U zF_C|v{kjqKcA`V+y`jhdSPV44adsCITcfTGbEggn20CzFp%<3#-m%tqt#_GvcLfa> zcTQM34pZv{19xt`iB)`?L-I!Z=zNERm+YQq9)L9aap75^jr4Uw_}#=Ly|n(2arg4H z-|l;oZvD`<^s(W$(Ln3}1Tb8F!P?GXVucB}6p>trtA|_qT`JNbd;h2{JM^8b}EtOC)V~qdF zG|LN$2{ix;^&`auj2*#e24)D;00@1UwV%NsdF+3R^Ei(ebf(s!2 z8u#V$w6f^Aj~M+UK$l~aPm@HL!cQ05=%<%e}WqeAK)b9gn`p>@6 zF>R;@^x*h!0wY{;)z?DT3TXz8Etel*KLVJy|!r`XBDJHVmnQ%c)L6WfoX z@_oq?Vj5r^fPoCXSrFPoH#Hn;BG1sU8g-1pquTN*Dt=+4ghiSkKx=^-IMQbxThZw< zS{P!hIczH%u&+?D_ZWL`3mmR=FBKfU_TMR&j~)=XP5}DgV@p2%8y8~_t|>_Zn!L5L z%HI_lK{4jzvfW)wzI_sMSemY$^`p%XZU+kMhrDbhw9Vnpp|+GevE%TppN;FIA& z9Krv-GF)+UaFL%F5YPNdTegS6ll*b*yZ5TmC%Id$Fhdx#aT(!|R`V)+QL{_?gI)Li{(l1%c<8RnZRzBSN>I@o7<6liJi$nuGu z8u=&(7PbaF7II`y^(n$a{n3M%gS9(UXQZt}5CXcEW4ZY#T}jjh1oA20Fe1MO(jE!p zG|@A1AzsdWVCea6d=rD@NVV$bNjF;+XCUHQc0oRBlOR0Q83v;#9hX+WX9fWvH&w^J z4xq~wKQA?kT5DMm?a(vc&0nxV z{!l)ofC<7O1;hXTkz>k|CRHRz0182h&~#yR5pvc*>J`)om7Ev>8S!zTY;z<2Os>@l z#Sb>dw~uttvc3N`WMEAvOpTQALA9T0Guihb-h}gsjLy2&3I6Ldw>-Y+6RJ^$6+Jcw zzX|eafOtFfWzahNhNZNeG<^3-p}PT6vJc)G*9aj+^A&#hsn0hkJ;u2-Av?qfKm9ei z&;>Eh2pVUV3;Iqb1%`ieu2|V{h(}=T*!dyLqrPxN8A6H1+`QcjS$mN3!7wK;#~^M^ z+GJR@u5E28;Q=z9ZUtePR_&S=S)Jdo3~Gsh%ehTVFdU}z+M|rbF797yIfTBbu6eZj zE(AF0ej4P^E~69dkZc9$ue|DsF!P^GxfIRiWKk_!0{9wKh;z48@w=u(H%?r`{>Fqe zZ(80|o+B{L_T2a!LDl{Qu*P_fHyYyF zHf614^2?LE@r#TSHe*rGT=rqHbX_*3YGS6}easYNK7fyzzpxizX>3<`Zy#~qbGSrh zg3z{salyYnE%qLwD{OBuxv5CEv~|~>On9JOakh!8UI*=Up4o`?FBZJJD0U>X<-G3M z0QB80gH~Too1$)HQ`z|dIIEud^L(@Si~8O2-82uxK;-wUBfYmr8G*Ne2~Uq{{#*G9 zPuWxf9U}hMI10mpvR!jKyY?%i4!o7D$27(TeDQgY(y<7~QS@~{N-&VP^ly9QI-H|% zl%8T_V)M-N?P-|ryta^!&Bze&O*M7hSbumM6zBgGVJTExe}epyEysd-J|khPp6a{Jj{%DLg!`I3Nnd{jMrH+)P4GfUj3;cBNAr#kVA{fPTB(ZL5Dm8WK9~3I zk0kg}ei44T6?#R`8TmBQ^f6Ivr{FJ_0OD2pnTM$-t~KAQ%dl0)%k4W7g*b7v+-NiDUm`VdK&&Z2bGLRNXr6`$zsK%DSyKeG8CCsb8U zt-?0k;?l6%^=glj&~c~3qv*-AIDZg65WccOn7!lyV(L3 zGQ%r%#YH^)?f5@be0zj^TD6(6>sRIbyiW%}XUS$?`vrF)Zx+eh<&h7ujaDF&({d#d z0WV1JRqSoRgt}3Z%(em>BP+Zr=?%%}E6ex(Ku{hmQjD+^NjJ&8rP*eIf8DWB7!Lhz+w{qB|&^PJ{1MO_s zG$j}C2R{muBo;8Hf#GbDzxKx=sabrw74Q#rJXr9CCbnoKHK7#jZ*;BL$RlJaf?TO! z3SJu9(Z1U4m?mbZ;X1IwZmXn>)tHEqhy+|aO_)Y`7Rk8bpk9aw`S^Zgr7Ol)Xhbfi zFewC9XAS6$VhH@458V<-jdY`=OG=9pKWrGqV2{Imj4@bMNcpr`KuSO|1s$jti%PT; z6|GxVB6_t5?5a%@(Hjw@1-FSF;Fv62nbbor!UGb*wHLETDoPLdRHfdTjx5uotpqWu zgDQifPsU-?4v~odN`qq(@-29FEUBVLI<8xMxWN z1X_trWzzGN?&XT4mv!l^Quy6xI7*V#=iwCSU#K{V>cYoLmxk5&8wHhVk2hTyGaB6k z)|Yw*nMG7&Oqfbtgo=iKNNQ3f^cu%rpjc!ch3*r_#U}p-I7TOD$znV*&@3Ed4qq^0 z3L$Wob|@bESkP*-X@rRh$rgK_taAI~#91oEUL&{iXIQqM}Q)9P|;0>Ft{Ff^h+vQi56qtP6{tbRq%;c!j_**VBmhM?<{N!@n&vG0KI z(POego;A&dhT-)4LeW7I%DnL~*HK|Zum;nN1GumAuF1V7`vK#QJW7AuKHmxD=66@R=9R_R)>H%&lHYyaH#QYox~|Z@!n5#AADA! zZ_ioz-8=oYy-ctd7!HkYr#q+qeEvnhD20Q=VeFYEY3Vr)nI3)(85ivIQ5Od0!A!a* z^rtboB9m?+idGW1km9B>9Vjs}g|LqZR!T3bKv2#0~GT{6FsvL>nVj!#f->+zXf5)`>PcPvAa#;j_qh3%9r`cN) zMg7OxV;c_uv*Z2+LOdgKMIU*L;QqZ;Wz_eqYrAvp2jm@5AE4 zyz~^a*SD9Y%&{>`@sk!gUfbi8KP;FySpORL{^q!(J50OxYaQM?0eo<#28b=`$P=$b zd|pGPD?00&C&KcMv-bDg5rNi6A~}zRmfLmjH?Ye>%X2;IU?)YB;v5j?^52>-6c;5W zsZML=`+%N&sxZxC8Yp7|->GGkZTo!Am(XyBsQ?vR-s^c=lw2uuHhA(KC=ZAtTEO?6 z(EJb)vb?e6!S3k;8@bpYZTSkjlYz6z}_Cw_O|Do?KytEyiA9MiPe*o30JIy)< zFw}uOuL@BjEv(ELV;87#AJL}wCe3u}+B3zt;Ph4I9k1M;4p2|H(#}1tzxmK|HxnHNdxfT0LjJpJkHLkvnm$@P3KB%HMtkkZi$T(H=KEYp+fjHBXOf za!)CL7D9>}*e^#l&+5AWB{p_!V0-5+$?=?A*Ke;s%D>$s4RqYlMY_%A7QHiI&qDR? zK|i8xkAPy#OJn&AYBlxi)8GIv@P}|(KYLCuKey01()8?X-Z}T!w_UD(dz|tv^Aor! zmbb)tpuQToYgQ{`7>eY7#5r?6bAPYjxByAEIseGiS~*7*|Q^*8p+ zp+#%u!ifXG{19EQ@vPi_NN^uXeeOVXL%BS4PXFz-taYypl}v9W@O1WOLQ);UJ zZnp+7fj!BvQBD92LQ~57vrTUs+^03Y!V+o>Uuf2sElLc2F`cI>5@Hm$ z65^s*Q^wk^o>b{i6r!)v&jT`TnCWa;Q*2u0cK^T}N0DGvGH;IJC&SkgqeY1dHo|iy z!GHTa)ftfXwOeqjGsAr(dH+UXf$7X2nV#y%1#5^@Y*T3_=`_bapz9xZe;hYwIEvWq zSXE85gf)n8fNVe@b6HTNSc@b@=eipcgg9O8l8yF5+?Yf3u*q-L-mmnx9}APMzKJb< z5hOv%s_v(e3$5T+k;QI=hRodjpMt|(Wr0UFDu9=MW|6>hPbm|@4xW3mL{mL;pAbLg zi2(yHL3^hfe3xXaN{o$IkI=QZU_0%=TQ`%I>c0}Awh+^Vqx77CHk!Yi7O#jLh9KOw zbKAV#iWN!jh4a;dE9f+&Ts$*YOW+x2sV`5CT|>AOID);irYRaCs5OL-4WXVgb4^E~ z#woWKii|pVNHiQrxj4ue=N@fMpRnaB7iV8snkY{kDA7l_S7&*p3o~#eEO(^cAwG3- z+_OK&g__}j2d1u6G@U~EH}Vmg8+6A8E(R4y^L3byh{w)HElaoq8dwDs(?vfbwQc2a zeRSi;wUs5 zL#yQ#kN(!*Ru^&l55_1owN_NCN*Rs=T&gw^4N*@Fr{GdFx>NI0RB{r-5VU`6Q#oU3 z5ornB{Wh{W8|`^v)X_6Zl<}1EWV@PnKvuzFAn^Z57oNXo#Em+fp>^g{^Q!?_KHX_F z_@bcusva`eazUhin-;t7*de6BbtyPkB+HTQ8b3!Esmlw0P6_lI*1C8k1FewDS$)~< zggWm7Jho0D!POrxX{94oOeTxv9m{%&Jl_TUO%g0>ebu5tFI465gC^=k@HY`dJW|~j zF-!RR6EoJI;+6vjh_*vh=*~?ECKXKsX6f9QauQUnbmpU1kb@sgjib_k)0tmWb#H@C#f9;h^N8G6DtqOkJ&qt80c(8j10U5I$Jq`J+ORAI41YNDyK_s-^Mpe*UtDX=;2|3@gOr8^7 zjNe;aOOv;3B7s{Zp2b?63{ts1xz%!6WwH&r0-bIDm+N@AndNLN0SYb-?$Gj%3La#@ zpt(63KQ78TH!}j=7f3lqUM5nz>H48uNBFCQ`vjD7;`qceIxUJ;HgYNk3VCAGDMg;~ z@*GR${a-_7)C2hVbp%9pR{FK2(--wNdGkDd%T6oBxm%7Om41m-VVb6ac_dh@g994a zOaCM3<#T=s!XTaaLux{#U%T(m~%WRm7VJ&|A(R+)^ zd77d-dUFH)&i|_~>TT209~g7c+P>w(tm67{@)_X{qMe=Bd0(W zoyCJafdD%n_AYXoYP6F@fU?|HSn?r|VY6bPVShQ0UBZqp7~ z8xf;%^xLOd0oT7&0FUk_Twa(Mmftfr!tL!NQ2cC#jy$PQed7+a_ayskyG!{)-TL|4 znAIParRu(;oHCiSpZVtX%WG$_%iTyPkwT|~Vnf3bIJI{@m3b&gQ0E6YwQ=GDzgjME+(r77aYf+IEm%i`B@Vvt~Y55g3YJ zdz%N_B)h)BK6=cwNc8Y*52}Ni&)0>3zUJxFbN$KVDs{aSK7kzW_Hl14^=dDymCL!^ z#4BeJpb9kcXGH;KoDr;j>9H{Eks$)rao`HYL0pK1k01A8B}ZKXhkq z1nm}Y{eGg_pEGk=!(5@iR3Xb4DR>3_*Lto+&Sm+o{VBb!;vzKjZAJ3TYaa;EGd!srX}P1-dz~ISb6o&n&26^< zUvcW4qSu8ODz08#{nn^b`rqDgcK-c@_Y(?7-wXd_g>KY$!CfL~njYEDOKuK!G(j>F5 zm1)5-hmHGjlRHc{hKmXsT}P>sTY@e68)N{kIAbi(4a$vS5a9vCa20k(p)2d%w4V1$ z$WYaaO$q7z%aA6SDsxBw5@EK@omUjC5I5e2Z24rc{(=7R|R!n&gj4DpcvJ zzAI)2A~+&M8IM6-*aJT82AblcKKyY(9-9jw)0_Z44CM{=?Noo;9ofmcd+ure2r?rJ z&VcG1VlS}fPc(+q%W=;WzuZ+!*^xqP2U0q?hSE%cybU>1d6W0_jo49h)z*<*m0dLt zAbvxs_V7ZfkCZY?7)Z*){(zarOzflgzXM3B$A7i6AYAZeIyPG%ecW9t($wZcS>N?uwBGR5dCMqN>$(4aRc z-5x;*XA+S{9vQXUuZ}1Y9m@|qkof9hN!Ea}`H7MjXlU5MYom&iJ4(r+q8rfp25GWN z`G1(gK4*gyB?J19se?&4WRh^ClHs%V#x*$*;m}70RD5vGkdRE_Ir~D{;>c)Mjd|cG zii4w{d0!0PXrzDzX0k@%hrUDzqgs;Rn3SKiwz^rjsKt3;7&A@NMxGcApLjC&a(~9@ zDBh+cCzlNq70B+py?IDQCE6u5jDJ&AUs#->`JL%)vnUksG7b`i|UVN z&JmM1N7g1`f@({);rSXHWz0>l$w>o}A;kuLfRqJ1IR4-0S)@qkw1vQ^q-F09qx8|f zRO~y$cA88~GZQFW66Yu4Dc%vc`I2F^{J_MEzATeg)({{CO2=ZGdjhjBZA}5SlwP2m zNu*$!egwd`O|@R-tieFFLoaKXcjKC_WpDnXZH@}?ZnaZ-C62D%um0FHO$Oj_n^=RR z=l9>Qeq*063D9!wfrspWu@KDN@rCylUTx<;0Mj{pbFWrbz4rx;MJDbYyuYGSo`s(KW?VerC+uxU(Lc}oke{zwmflE4jBTH?SF^v)uxP02*OMaRSFz&cJkTcH8j?S9=W5E=BC<#%@5m>c1?85#lLEzaD5*E*m3 zczOV?GCZiivV?I;@0pB4aGK?DcTTUnRH<%*$4ZD*O!*5f;n1Fcb} z16@HYC&u1)&W;}Z_$!XQ|NYCJFoHW^flAeTP?$(F6mQEL5GyNZV>39fj52r)Ne3=4 zH*&RzUhZA<0e5I>AFCX6J+AhpHDkkV?J85RcA;bQiww$$gHG#pV$&&VNBwV8fhTJ& zDqW#SzSG7+-kajP=DW5goZ-U<&90iDg%FU;Iq(~$kqg7}xt#p!y%3LT?KYDtskhxJ zaPNae=ybn%d?*SGBmqcAiq1eGbRS{<)cuHOwiE9BK#l2o=f37=QDVvG z^TtIvSz8OPPhT8tC9v2`p6DT=eti2unsZ~O_fL!$A|m_~br^~G@LjVj)V7b2B=0SJ z7P7ppM5IU(8Y`>bI464rvd_H%3iQ;{@6~%gNjTGMxbXlt8*M3Lp-O@xbFR7;Eai{* z5#WjjUQ_MCjq1J7p^=+giGW1tAJrcdrI1LJTT!*V_sv!Zjj)X*Nuqk+lX!mu<;{3~ zs>Ri}f#bf9ct+FJLfLHdO^%1pE}#&_PfDAO5#OB|GDsO~B|-c_p!+Ij(QNf_Crkz} z{NOO0z1ZJ1>(tl&_=TwblLlDgut`&Xey$=V)Ka(`+HTx9$|TsSF-1m+Esl{mN)831 zfs3ve#^%surVRmFflXwyoA*oaRD{wz=`+~4n* z$p2F-Nj%i8sl3qT)x0@q$8;`e+3t_LO^O^a?H6EBcQRo)JTpjzANUtOHhO`1t!UqErm;(c3QB=h6ro*1;`0rvmjSC z3ROiYMetIQiX$8o=62&y61LCo%WoH>e-YVe{9??ECa+8&fQDh2M^as^@nhhtkZw>0 z4~01M#czP|hdN150%?jYJpF-ACcvbdibDliN}+4x%nbHm)R>e@4ao;rqg4d&!o{YD zfgs~FY@}Ky`C9$c&%TvEw2J-8df?b0Lv{Z~#sTi|Xca6&s`-<|thdZI?~nCtZ4-~3 z`wzEk``7>6ODYbnbjqwG>;0KP{k;kK^L@XW5Wb_c$^024)3zjLLJ07QLmM=X`BR)y z{G%YNKZ+ziiG*d8%!e${#@42f)9DwQUM2ocOIX=r!dHO7Qar)sUh$mq_T*{NX5qQ6 zX-d!%pmlO{`u5a1@$}4a_Vm(GcgIzBYOhjPLtb5v@kQm?<%1w2i1G1T_>Wqc%FCP$ zD_bg_R_gCXAW72NqI(oyJi=e%K_s)1WU{qN=h?!7!(mzpk9?-R_eIJhZetNyOYJ%i zefDL{G4kqWJdL`#)s4OVgWa-Y1Twl;o1M6TWN4|=$ws3PF&RCVnvhwgF#9h)1r&SzGMO=NE&TSf>ofGLc!jC@~N;#^(gAN%xkPC2qu0tw+ zp)&8~D%bSWKQGnldPbYU1%fTE)!+<~jZy{| zU3-*A0~Rig^`_S+Hd@^c>AV`p6ZXJM>v@j6wYA#3UE zX3$DW2#y0N&duL;6L4jAOC)UVf~S5`hyG1SX@6jIkjeG&Mw@auxFfjZB~;7|tiqr( zG%w~J$ie~(pO+|-g309eU4#3Q2Vy#HVAF~3G0Ua&zT-@jje|_s3g(O-&_!t?1iDfs z`;*cF8dV9E#7M5ce=pOu?I8=bhXq#@%n$O>HlquPm!R%a)we_Yt01n{sjSZxNe1il zipFM+*`e?jaoZ+ro3G&P= znb|LNJekeBEtzQNKc11#%xrmjo*cNqbI>lhzG*J)Q1R3yvS-z3pLo5NeJ;}iB*Na$ z^VSau);{MePs*5lsocw_o{vtmCJhW;ne9U>CQn;;|CX0hSKIFoSP!50&wZ@#1Fi4d z&z|`2Da?Jyy#&{up04s2g@FvgCp)L@2TH)Ix=Hj9w0@2BHopGAL-f1>eRj=xRNdl8 zF~IM6c~<^@#g>N|FTLj?`N#)ZXSY!%FPauztZO8XjnCe1XD=`EU*G5i;V-^hWa^GX zHZI=>puRnmyImr^z6xx#_|-FHMCfKV^R!3w9mn1;P5BT#RS|6m?KpewJGYm?59#1U=a=k5!HMx9^x* zfhkm5DuRr*M=H*k9nnM!e^6H_`iJqa3m81UkL4%+yGZI&g2mBs23qxF>AzDjXfPUj z|AOvvYJ*`*G*W8!@Yv_$=G)2pcI#A*pePgAV!RvoMc8Z(4EkQpQu%N z{jAXR@7qd#$pIH63`2erI)s;?4fbI(6BR-W8Px=t zWb8eJsnK7}Y!{m1mDqi@rp7rp*y51ccf1XXd{gWp2}>fUNzx75)$TInEI;HO97x$I zxFAX~bVK|>9Qa?C{|j?(85UO%wFw4-yE_DE+_mu#+}+*X-66QUySq28!5a7A5L^=6 zf&>EFnVD~PcILQN7x6VEFzHgmlvyHc(Mtw|z$-W9R2^W)fk`nq5 zCeE!U$Y5yg)r&)Eo~Xqx0JKwgFz+vkHmNw&T^VoaG(bs$k7ebw@WU_$ZVR`IonnN3 zBs{_b^x}OPLWTL^ZE_&evD1j=+#`jWeiD&ETU#{axdd;8g1EsF%CzJX^Un^_QgME~ z&{#0rCTbtyaD=D*)!Zj)=}F26)TZR=Oae<=h*Dp43++=$B(d-1{^SNe-7SEubR)?cmCS3oYfK4aUV-k2g zGSxq;QOR^IpRIoxR}Qlqg-Y2;cj0RGUWhJG#Z7 zy>=^A^@im}FSEn(?!w38W#tq|^^NQ9>=t*w{+hArFJ)lvMZI`QTK!X-fXSGtr!Uag<% zbo~8VlXk$~#Z!s2WS^HiaEFlR>bEC5vuWb!diM}k#`U?p5!sX8U|r8H&<8R}yFuwKGd(U*HUaY5Cx$BuEK-JEyaQdt+w9>2&SKXq7HNoMrSu+Wpn}KSR9)@|>$1`%?7_O<~ zQ#jc-Wm~7Jkl?*W`HjdI^D7J+iwINLINfE3euJr=QRdHj!t$`6Pi z=#hsSd878jyDoCX{$Mla8)KmpbECu8^)b7`Z(;%-@YjZ`+trHcqw@Q-d-p16sS`Fg zS^K@8DGLin4(~W!#D^@ic59Wqw10^!cKPI*8wBR$@C;5NWnEk9v(8?B?@QTNd(AoV z%`7w=!Iy*gDS^@Ndoopg=Gx5FC*MR(9CtX;Ob&RBRp?S^Q~HKr7?4BjdvgnpNW$mK z^wZ>#R79G@gQO&#jOB3(B3ir#?*klRX^$n)_Gbr`@X8 z#lxEYWEbAJTDBD|3qNwmH1m-@wxOJ80WTCiYC5b-KAhPctbMMPLI3H&hh6q;@t_uWKwI^j=rCn6CcsY?2p;1q}FYX zh*r;u&?!gD5Ti5K)-ufX{Bc2{wKv8jZ3dLQQ4{%fs7New+_Xz?D#f7)UWwxbr_)>X z@%S01H7&8{2kF45Fc}qc!APnmm#CpMC=+@ZXHquMiebt~;GlRMRgH|@Gz3G}bQ08* zG|99n)i&nz=%vpPl?($Jh%y?5H7jOue>bI)(JieE=2976-sPpzE)eAoQ;K48E8rk! z@){7KvJVK%DT({saZzd>!^BRrjaFbb6vek#<^ely?kSf!*%wABdOCj=jD-I^V?BN} z;evu{{`oRZPY!OKsEg!^Y^2SMxpwHNOfj%I8RvqIsuv~cnX+2BdiQ+9`tZYKkI`YN z&d(ki2OOK+-1@%obsa)L1XhMcqp9BPPi1F^2p)BzD{~bQsMmSaQx^CM{^erW4V3P# z?fFkyL7vT&{(`#x+4Mb96JA_eDC5E}8x4iQ0#UX$2^>bY{IS&8tV!i@23y3AG7nKq zz^{}UCD6H;yjk@IYj>*7CZ0a{N&cKzoG2LhohJrSUR{#Zz6U0rfq^Ot)5+*Z7{iAn zDy^M(>@Ij~@&K!3N>Xvm2;>Na5!{3t1hnL_Mti1Z$N12e!cy=x%SHyLx||5k zxHXZasTPWWQt6FBng&$3*a9IWR>rxfl+3Tl!?H_r5jR;kV+e?#nnXdnRM8xT&NwV0 zT?)f9vTvW4ipw}*Pb}xJdo9qvVCRm@Y(~KQ$WgK?yXLFO$*`$MhmF+|`dt%SB&}5h zU3QnGjgUpx+KQgM@dmTL`+L-^wDbz_C7)?Tei#(Py~WTTS~WgHR5%}XmJJPBKVkXR z)EAxx?g~q(i*t}?o+8}cI7cF77Z4!k6i-{vYTgS4G`g_it8Em}Rou|(Zc7Mql>TzD zVw{q+wZ=O}K_o{)*WN-EdSj|bmfsp>v>DMwYQ0dFblYu)zu_WRDb>)KK%tLaDvpJx zV7H{qo{4zcYQpN16y0MG@gx)luvB>ZDuY8|u!JZWivg2+F~mbOyu2U9;n%-9``fh$ zx?qws?+$SPR4QhE%&TV{lW`wbp(`(%c<|*e^|41BWrE33u3|Pf!<>U%OcmL*W_M2f z8D=XwKK(PoK^aX^A4q*{U7AbbzwKNuOtui(L|5N%AyT{GqnyM+4JiU> z%B=?E&2g9@QX#X&cOQd<`>4Yk?ug-@od#KYOG;!nqYkp%up9DB*`CThRjLCb;$^fz z31}6B)qeUhH3XZ{*-6xaQK>Y9B*`UG_%>|2i=s={yi*d#wQAwUyf>1-6o;!{eNK;N zUS6kjj#BN9m2dX{{l%l~#oVP^!9yk5e~K2XKmM0LtQ6oK15WWf&wl$|!5h(dc8|(! z20yrN#s=CjzJqE}YZ>3DUv1mEQ#5NuaZ8R9gOqtuXqh^18Tct9@LDyang~*v{YsD# zrR2obFj{D%gl0*pl((XjYL_Egj+)FM<>oDz&wLW5s*eZ|k@afeyNsGg$Lz3z9acri zokOXNrH$hzOf($j?fbb!5k5pybV|9WQH$piQjgk&IasC>bi^yItsL9-Ht`!i8t2Nn z;ppunsm${+L?Oeqml5t$II*fCKN6+RPJfo$Fw6sJH%m&EF=*{78Xr`H?Q@UU29JfH z8L9Ahn=CJ$MsvOJ&&Uu5nrB|N3TU&W?K46!SmjO(PDFEDz{j*nC!ewOy!0-|*AE3_ zOw{?2?mhyd&0R^^G6a%-#3=xb59B)M*3Cguf9^Or5+wUmaR2&*#0)yRJ+)Dp%YF1dRH0LCrX z@;_{G4Kx9@^SPKAP)-?4YA(x-N5=KuKb3M(AN$JopSz* z2duLyQ={2-V|XVB58>)Hl=|dpCF1}}jL~N4y%CH}PH2}a%E*+}x|>kx!dgp_KzIX~ z-6n>a$%UD!uod2M&rlDA-Y(TS#BSoLi62)d)rl&2LUSXcZRDI|t!FOQLs`VkdsL$s z(ZtL{IT2;X^<{HDN3H}m_p)2{I4pON}rRpO{LACqR*4g(&J>wC;9^jhW-N!IP}aT zN|mR&L{QPj)V1Ypu%s?;X4H*`88t$R?nEwSCytEE$YrOWp$3l6HB({b$G{8_iRQFj zKpQT^q}NDL3c$eD3gRSOvZe9n^+biGs_HuoyP0~5i}_JuSw!f6#n#i~SBw{AB@Bq) z@5HOSLKF^)Iy9`#!}?Bzt~>riY&9=-P;28hA=Ishu)^3qp&sQ@oULQ8 ziG#d9W*9qxi-RM!I4v|_#a~^Nr0B>upX;g8PCTFRZ>#N8L%!dYSEG4_e>ksX8X@qpm)We5#|p(Ujx6`l}LI2a~z~d`gDFZP#JV%CO9JWD2!5Yj=TP!om#fw?ygCay^}IP>T-rd zPstiikbv;Vw6YBFwWzvR#D-XA+1RCUu$>F1BSTW{Q}4;36FlO)QAR0>7r|MAhc@7GpTYA{NvAhH#%DcDOwXIyaot3^c#QCL%9k(#9ca zPB^)T2#?r+0>5UlK6a3*niB;ohz;&UcX8O{$6u7k6JN1bjcWabn9dnx@~e~JM%FIO zKG#lQ&sc9ZNorP85jM0A8&q;WxU$46krYE$%ASNuV8(!%g4Gj#^c}d;AmJz&m4yQf zGOOAyI4t$1H>7muq8HDZPbvdhs*vjwqFKj3T#xr9_WmiZbkaX$EpYG+A4mCO-88g6 zLo7a}>LyI913DtF&Dt|ggZnYAwc!zxbsjM%=A>oX}P^99j2(A(5mhE?E z^ieiAZO1V~I4~^&ESp5fQMMGw<{=8nsF}|(n2Qby}>C}-y5C_wQ3aNLj#gj@juuf4U zHR3|MIa64)vN&m2sB93KBk<>IXc?Bm+K*6{Rd+{A6Wr2Thk8sL)5DRNu$mr z4PmFEx^Wc8XHtk?cG>qL)6sgu=U2L*209tpecbQOUZ^lQ5U4tr8HaWnmBgyyGSy_C z)^C+1%)5qiL?zXtM-_9ipoiLCsR)~b0DnjtKUM4mEMh7BL6T{$$BIrroWG(2GC4zJ znTXjHXr|z}=?~Xk;pRp-Tpc1}6SU<-U}ZB8)E3ryLp;bMDD#cN-! zkw8&;vMveI^VXo!P2&U9^~fw3^ziZ~iS43&RsEFRlnu*~JbOnD0+=tH#+1HXFpK8x z1Hf(^P9j8KoIQ?gv`Nn=N=jfVyj>EK-}YiAh63oE?85#+G-l6HKaQ=KO)55J-ldj% zm2E8pc{p;v2>Z;SwR8=YI-A`zYgu-^=@`Y#tOP^SOym*jtC71h+Xniw# zOC%~&Dr#s+J2UJ`1c$4DAZG|9q@TyhsXOUR%VU>c*%PrhMQHI21z9xyWL>rPR}gDXQa^lFA>Fgto0t%D z9i^AOXKsFhC({>E5PO>{UfaLBgub~Qdx}F0UQ`KOL2hJ+&rP5NV$pLc-wQJ4DzA%l z0b)|%bhZaF#%p#f;isElKdHxw0c4DTm7^5oU)f3MzH5Mt$>;0Xdno*Ar@a*X7Ku;MZ_1Dv<6-WVs!T={GxI7Y8Lb-APDXaKk((-i`BRDou>n%Kg&k1d`AAC^KRsA zr4*zo6xSf$+xMZ*De(QJ;2p%*fAajuL^PO6eG@R}bRB-O?RL!XD;RNtJay6bC*@>Y zW)Q!INjv(m|2+UQMC?&84cSdt^wF469DDqN+2a=BD(FgY+v_s8X=z+-wT`98|K`LW zaqelG=NoI+TwhZ>B$Lh-a;K!!t_zBwh# zeZD6uNGGaaLcuikiK~%xK0qGF9u4^I)A-iwCH#KrCHyG5*R%8Y6*=!^`()>RTi-Yy zBaSh+=i%}s<@cq!_a0C0G0)P$yt-o&XO;?x98}kT=+@sFqvkMGbv<;)3Pb-}pI<(; z`SF-WFFAG+dG6bQ6}{CwIhjL(z+)BjkHERRU!Hq@e*TZSuGt;Jf6r9w`iGx1k(DUz z3aH1*kLu`%U-1ne!>@b_Y@0PBPhvD?l1>b@;qM`(Vvw6}xZ9bo*CPv#!d(T&ll3c; zNH19!~ln{{6nckAn7Ujh<42p5*#(=ls_TU+%vB-Fe>Fc{q7*^Lmf=`uteF zI?I|;5#mtC0ZU?JSM+ou{PHaL!>>TjPt1D5?J8i^?aI;fU;}m1B|PGcsbTFY^ih!O zxE%@mwrM0zr+4nx=Q?$}t}!zst2$-w#6~vBb_hh&;OK1ilk}#%TdDT@!IQiG{^iy8 zRhvAH(l%R%mcmppE#@IMi-f09~WzeGqNRZ(WNZ? z7Qg(QoVrtT_)ys#oCDhj^5ip(t181(FgDS&+{gZ!83~(dpZ+a9qEdQ)+^<~q!zqTk z_7|Ur^e~?LnjFrtkt9`Mk4lf0Ig;gs{<6}d!L%7oUV2elsEVL82NV7v>~N2%mYfK5 ziFrHBmi7UsJ#|(3VLxd+d}>RVqO1avRdUzY+RMIL-YjBvdgV3pF(A+s8_Dg-_Dmr? zyxY zGb%7#T|#dpOgY<~4U=jK1mnS#e(QJTz%s-akPzioTd9Lwv{gR?CW=KPw%q2?3F=GJNZ8Sj(`YO?mR>K9YZ0ayW2VjzA?B=j?(tExZwfwp zV<4}OMwtniYQO4qy!%*z6>>s?BzWDBtHWDGWYx|668PGIagbM+t2KN%a*Hb7s_jiA z+?$4E`9(j;$b@U7Cm>BD!0D1a9YGsl-YPpzSbN);d?l+7IPPowiB3U~Z5L*Icqf=7 zN><4-VyTherTUqZ+yyX%kPEOX@#{s|HN!ddTKRtt-h-Nj}i z@a(PZ$!3v6r=Aka>~gd%>e&2(&|})*TDfc+FGC(73f7cSaQ7rmkcXk` zS31b~>t7*B2KcOr_9>aD$r6yrd2(h8{toLhfP98hM7ml`x$@b{99v>J9`-P4B8ocP zy2)kIxl|NQVapaw{7rX2P|K8o7gk!(e1wL9+igGR)1=OA{wb4(MQQ$@mS12jf!|iu zjKmOWh5~B_1Z$8pSht2i3%<=Gihp%ZCnX^te~+bpa6Ow_Bvsn@2Pte7f24iBqu9BT_6*fu|esFOGuO<5LHcm3qBtq`Pgmu0G)2pP8V`#=4c=*Iy^1eERM16B*_f zP}E)7v_tJKa-biUd!4M8_QpLlwXWvT>l7e3VvG2$#InjyzJDOD0z2q!>iwe8`{l}e z2GH0Q=oyc2zdak#^3u5Tei*9Mf5m+D&J%dH`WDRkEav2MS})7Z)5?_-i-Yxgd1B^qsc3iv&&6e~7kl7Zavv;Pc|VD|a(0hULi!0*w?_b)!Z4pnO! z>_}wa@9K^RiQVeEt)5UP-#w4S{$K(b!yw!f?)Qw2x0Q#Tvm}u9j2~6t^T}UNBtIg7 zykY8fQ-APg;7NbZmEYM*bhAR0kknz;zRGj?J~CZ20; z{lGi%Xq;;27nH6mpgUB!_v!d}mL{3` z-RsNSmeVs&&<_6JsmHt@W~o*YFkyD@Pq%X~q*wlz1_JGYRgkwHG&NVFn1)5~fyXx& z+75@H&CPyM%_$4a?{GSyDQhxPB?_(FQZkyO$-Is8II<)<#gq;u^?VulNgut$b9p(b z$n$U;et!`NNoC?tXeG33=pi}mu5H=V81s_UisL+8Rny{bN=Mp zZtoHtZMo0$PJ_iEf@~rxMzL8Xu?GuUE~dp)&xb~dffnKugyFD`lj8@a7*lK?E`6Xi z_Dt9Gdg)Uf*h4JUtw_|1MY37hnS*Dh_kXXPA7bk4xyid%dq3W!Wtj>#afUu6OJUY< z6@%FXCh*J%@5D8gu{UVIWDDG~^i}mzR`h&ZKO`hsro7kU{{st%W}{Y(bFMC~iz|x7 zSA~8=$z`>M*RUSOXMTHNs!xN<9SFwOk+hcwsk=eVV0MMRb)=?ocSH*hHeUAL5=fiNs?*$=6qYPq?UtL4sp3!6Pu_Te0Kwgd0dUNBR=!d;76eP4&&AXD#PftD zUl*pWwKY&^V)?PojASAJ-!TAEcrO^>}vA&xxe` ztK)fqMEDMI05{nLhfhi8Xz~T=;){$qxhd;S*vLEi z$MUf}@zBxmKlUrULOjsSDUSL*kE7tewoPO!GQ+!ZyYIs*mTb+KRmpCu>A1sCrb%;6 zZDONShj4kTT;p9X>u$KyIQa`oJe zQ5DK1t~xGaHy6g=ML53nj_nrk>ysRCO@3+eeBlCENEP>5yHM7zk0;Y(9x5)RApM}T zpO>N%%4LG73Dg5u@KWe(qPV)-mPSeh1IuI5LhKxfKd~&4>EY)rdb3%n#jY9Sh^&mY zhOBcS=3G=q!jBM^isB+jG0#roA-R$vB#u{Pgrww_?j6R5a=J3ttg;Boj5EneyM}-; z234iakM0Q4pfLxMQ!$)8yP!yPv$7;Nj1*uJ#etaNA7q_fT%jnN^-E)hYh}qrN%q85 zO(GDyWonQQtGHRi4Gao-^X5fWs|0jTIa(A5DmC?aIR$0>td6*y6ahlHCD&z%BtC)_ zkbhp3$v>^^`xopMybQAhb<7uQEiU2owOxAdk)n|l15Ia%El z3h}Cmi|ETGQ7w#C$p{b&2TG_J(Jtne$D>&Bldy+@C=FYu2b)Kd)O1P=E@nvW%}{5l zv%}Gsl_jy$I((0GxM)30L*^XsBMKU#?b(vaoYE03DOX;-5-;WCl5mU(&k;=n@*IzQeoceUN3* z`|%BWMM^Zo486LNKeAga^w;SY+6j}7Qh?!q4KO`V=TxQ)kA`JvT5J`^cva0kjo%!!wkNF0 z2~{agkgdCx5rQmiMh1sJ`J8C=UHs}eV;OsPe(MPuzIq;74NA6%uR!r+aueTjTAgeB zwzt4@>P&h;mDpF$S7=_@y`k=xWpco0ys0>fSf+JbL17AJIXcvG0~U$o6>EXs z`3G&JlEqboL3?$PNq!aqflVU*gb2!JSrGFN%xH2;;&nb#B>A$={Cc5b+i0QF1`*gq z(FML8I+&5HS%R