Skip to content

feat: Phase 3 simulation core — registry-driven world catalogs (D2) - #29

Merged
chiruu12 merged 2 commits into
mainfrom
phase3/simulation-core
May 31, 2026
Merged

feat: Phase 3 simulation core — registry-driven world catalogs (D2)#29
chiruu12 merged 2 commits into
mainfrom
phase3/simulation-core

Conversation

@chiruu12

Copy link
Copy Markdown
Owner

Phase 3 — Simulation core flexibility (D2)

First slice of Phase 3 (post-0.5.0). Makes the simulation's content catalogs registry-driven instead of hardcoded module constants, mirroring the existing StressorRegistry / PatternRegistry.

Changes

  • world/registry.pyEventRegistry and JobRegistry: singleton default() seeded from the built-in EVENTS / AVAILABLE_JOBS, plus register() / get() / all() and _reset() (tests).
  • EventEngine — accepts an optional events=EventRegistry (defaults to the built-in); content is now pluggable and per-engine. Replaces direct EVENT_MAP/EVENTS access.
  • WorldState — seeds its jobs from JobRegistry.default() (local import breaks the registry↔state cycle), so registered jobs flow into new worlds.

Why

Adding a life event or job previously meant editing catalog modules, and content was global. Now applications register content without touching the catalog, or pass a custom registry for an isolated set. This also unblocks D1 (feedback loops will key on event/stressor maps).

Tests

tests/test_world_registry.py — default seeding, register/get/all, replace-by-id, singleton, EventEngine honoring a custom registry, and WorldState picking up a registered job. Existing test_event_engine / test_world_state still pass.

Docs

New "Custom World Content (Events & Jobs)" extension section in EXTENDING.md and docs/extending/index.md, plus an architecture extension-table row.

Gate

922 tests · ruff · ruff format · mypy · mkdocs --strict — all green. Fully additive; no breaking changes.

Next in Phase 3

D1 (feedback loops via event/stressor map: world events → stressors, stats → goals, outcomes → identity/narrative), D3 (relationships/mood/chaptered narrative), and the deferred B2/B4 (cycle-phase hooks) which now have a real consumer.

The life-event and job catalogs were hardcoded module constants, so adding
content meant editing catalog modules and content was global. Introduce
EventRegistry and JobRegistry (mirroring StressorRegistry/PatternRegistry):

- world/registry.py: singleton default() seeded from EVENTS / AVAILABLE_JOBS,
  plus register()/get()/all() and _reset() for tests.
- EventEngine takes an optional events=EventRegistry, defaulting to the built-in
  one, so content is pluggable and per-engine; replaces direct EVENT_MAP/EVENTS use.
- WorldState seeds its jobs from JobRegistry.default() (local import avoids the
  registry<->state cycle), so registered jobs flow into new worlds.
- Tests: default seeding, register/get/all, replace-by-id, singleton, engine uses
  a custom registry, WorldState picks up a registered job.
- Docs: new 'Custom World Content' extension section (EXTENDING.md +
  docs/extending) and an architecture extension-table row.

First Phase 3 slice; unblocks D1 (feedback loops keyed on event/stressor maps).
Copilot AI review requested due to automatic review settings May 31, 2026 01:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces registry-driven world catalogs (EventRegistry, JobRegistry) mirroring the existing StressorRegistry/PatternRegistry pattern, and wires a D1 feedback-loop layer that propagates life-event outcomes into the suffering system and the agent narrative.

  • world/registry.py adds two new singleton registries seeded from built-in catalogs; EventEngine accepts an optional custom registry and WorldState reads from JobRegistry.default() via a local import that intentionally breaks a circular dependency.
  • D1 feedback loops in daemon/loop.py now add/resolve stressors from event choices, record abandoned-goal outcomes in the narrative, and expose agent AgentStats to the goal-generation prompt so a depleted agent gravitates toward recovery goals.
  • New stressor/stressor_severity/resolves_stressor fields on Choice and corresponding stressor_added/stressor_resolved fields on EventOutcome connect the event catalog to the suffering system; built-in catalog entries for emergency_expense and burnout_warning are pre-wired.

Confidence Score: 5/5

Safe to merge — all changes are purely additive, fully backward-compatible, and covered by dedicated tests.

The registry indirection is a clean drop-in replacement for direct catalog access, the D1 feedback-loop wiring is well-isolated behind null checks, and no existing interfaces are broken. The only finding is a minor incompleteness in EventOutcome (missing stressor_severity) that has no runtime impact today.

No files require special attention; the daemon loop changes are straightforward and well-tested.

Important Files Changed

Filename Overview
src/hive/world/registry.py New file introducing EventRegistry and JobRegistry singletons, mirroring StressorRegistry. Pattern is consistent and correct; thread-safety caveat matches existing registries (noted in prior threads).
src/hive/world/event_engine.py Replaces direct EVENTS/EVENT_MAP access with registry indirection; adds stressor_added/stressor_resolved to EventOutcome construction. Logic is correct; follow-up resolution now goes through the registry, which silently drops unknown IDs (same behavior as before).
src/hive/daemon/loop.py Adds D1 feedback: life-event outcomes wire into SufferingState and IdentityManager; abandoned goals now record in the narrative; AgentStats is passed into goal generation. A minor redundancy exists where the daemon re-looks up stressor_severity from event.choices after apply_choice already computed it, because EventOutcome doesn't carry that field.
src/hive/world/state.py WorldState.init now seeds jobs from JobRegistry.default() via a local import (correctly placed to break the registry↔state circular dependency). Functionally equivalent to the previous AVAILABLE_JOBS list but now respects registered custom jobs.
src/hive/world/events.py Adds stressor/stressor_severity/resolves_stressor to Choice and stressor_added/stressor_resolved to EventOutcome as optional nullable fields. Fully backward-compatible additions.
tests/test_world_registry.py Comprehensive test coverage for registry seeding, register/get/all, replace-by-id, singleton behavior, custom registry in EventEngine, and WorldState picking up registered jobs. autouse fixture properly resets registries before and after each test.
tests/test_feedback_loops.py Tests for all three D1 feedback loops: event→stressor, event→narrative, and stats→goal-prompt. Covers add and resolve paths; uses monkeypatching appropriately.

Sequence Diagram

sequenceDiagram
    participant Daemon as HiveDaemon
    participant EE as EventEngine
    participant ER as EventRegistry
    participant SS as SufferingState
    participant IM as IdentityManager

    Daemon->>EE: roll_events(agent_id, cycle)
    EE->>ER: all() / get(event_id)
    ER-->>EE: List[LifeEvent]
    EE-->>Daemon: [event, ...]

    Daemon->>EE: apply_choice(agent_id, event, choice_id, cycle)
    Note over EE: choice.stressor → outcome.stressor_added
    Note over EE: choice.resolves_stressor → outcome.stressor_resolved
    EE-->>Daemon: EventOutcome

    alt outcome.stressor_added
        Daemon->>Daemon: re-lookup chosen from event.choices
        Daemon->>SS: add_stressor(stressor_added, severity)
    end
    alt outcome.stressor_resolved
        Daemon->>SS: resolve(stressor_resolved)
    end
    Daemon->>IM: update_narrative(agent_id, event.name, choice_description)

    Note over Daemon: Goal abandonment path (new)
    Daemon->>IM: update_narrative(agent_id, objective, Abandoned)

    Note over Daemon: Goal generation path (new)
    Daemon->>Daemon: "agent_stats = _stats.get(agent_id)"
    Daemon->>EL: "ExistenceLoop(stats=agent_stats)"
    Note over EL: stats injected into _build_prompt
Loading

Reviews (2): Last reviewed commit: "feat: wire simulation feedback loops (Ph..." | Re-trigger Greptile

Comment thread docs/extending/index.md
Comment on lines +479 to +480
Register your own without editing the catalog modules, or pass a custom registry to
an `EventEngine` / `WorldState` for an isolated content set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The opening sentence of this section says a custom registry can be passed to WorldState, but WorldState.__init__ has no registry parameter — it always calls JobRegistry.default(). A reader following the "custom registry to ... WorldState" hint will search for a non-existent parameter. The later sentence in the same section correctly describes the actual contract (modify the default registry before constructing WorldState), but the contradiction in the intro will cause confusion.

Suggested change
Register your own without editing the catalog modules, or pass a custom registry to
an `EventEngine` / `WorldState` for an isolated content set.
Register your own without editing the catalog modules, or pass a custom `EventRegistry`
to an `EventEngine` for per-engine isolation, or pre-populate `JobRegistry.default()`
before constructing a `WorldState` to include custom jobs in every new world.

Comment on lines +38 to +44
@classmethod
def default(cls) -> EventRegistry:
if cls._instance is None:
cls._instance = cls()
for event in EVENTS:
cls._instance.register(event)
return cls._instance

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Partially-seeded singleton visible under concurrent access

Between cls._instance = cls() (line 41) and the completion of the seeding loop (lines 42-43), the GIL can be released. A second thread that calls default() at that moment will see _instance is not None and return an empty registry before any events are registered. The same window exists in JobRegistry.default(). This matches the pattern in the existing PatternRegistry, so it is a pre-existing concern in the codebase rather than a new regression — but worth noting since these registries are now used on hot paths (EventEngine construction, WorldState construction).

The simulation layers were modular but disconnected. Wire the three missing
feedback loops with targeted, additive changes (no new event bus):

Loop 1 -- world events -> stressors:
- Choice gains optional stressor / stressor_severity / resolves_stressor;
  EventOutcome carries stressor_added / stressor_resolved (apply_choice sets them).
- _process_life_events feeds the chosen outcome into the agent's SufferingState
  (add_stressor / resolve) -- add_stressor already tolerates new stressor names.
- A few catalog events declare stressors (big_loss/burnout cause; windfall/rest
  resolve) so the loop is exercised by real content.

Loop 2 -- stats -> goal generation (economy-gated):
- GoalContext.agent_stats; ExistenceLoop takes optional stats and renders a
  'Current condition' section (health/energy/happiness/reputation) in the prompt;
  the daemon passes self._stats.get(agent_id) into both goal-gen paths.

Loop 3 -- outcomes -> narrative:
- Goal abandonment now calls update_narrative (success already did); life events
  append a narrative entry. Reuses the existing 800-char update_narrative.

Tests: outcome stressor fields, daemon event->stressor add + resolve + narrative,
stats condition section present/absent. Docs: data-flow + feedback-loop note.
927 tests, ruff, format, mypy, mkdocs --strict all green. Fully additive.
@chiruu12
chiruu12 merged commit 8e2a996 into main May 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants