Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .context/retros/2026-04-12-1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"date": "2026-04-12",
"window": "7d",
"metrics": {
"commits": 5,
"contributors": 1,
"prs_merged": 4,
"insertions": 48760,
"deletions": 23890,
"net_loc": 24870,
"test_loc": 15699,
"test_ratio": 0.32,
"active_days": 3,
"sessions": 4,
"deep_sessions": 2,
"avg_session_minutes": 55,
"loc_per_session_hour": 5350,
"feat_pct": 0.60,
"fix_pct": 0.40,
"peak_hour": 23,
"ai_assisted_commits": 5
},
"authors": {
"Poor Coin Pepe": { "commits": 5, "insertions": 48760, "deletions": 23890, "test_ratio": 0.32, "top_area": "frontend/src/" }
},
"version_range": ["0.1.1.0", "0.1.1.0"],
"streak_days": 2,
"tweetable": "Week of Apr 5: 5 commits (4 PRs), 48.8k LOC, 32% tests, 286 test files, peak: 11pm | Streak: 2d",
"test_health": {
"total_test_files": 286,
"tests_added_this_period": 48,
"regression_test_commits": 0,
"test_files_changed": 48
}
}
8 changes: 0 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,3 @@ skills-lock.json
mcasp_pencil
mcasp_pencil.pen
*.pen

# Internal documentation — kept local, not pushed to public GitHub
# (SPEC documents are the project's IP/moat — see CLAUDE.md coding rules)
docs/spec/
docs/init/
docs/BUSINESS_REPORT.md
docs/MARKETING_STRATEGY.md
docs/OASIS_vs_Prophet.md
14 changes: 3 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ that combines LLM + GraphRAG + viral diffusion.
- Master SPEC: `docs/spec/MASTER_SPEC.md` (index)
- **Context strategy**: `HARNESS.md` (six principles — hierarchy / contract / verification / cognitive allocation / parallel decomposition / decay prevention)

> **Note:** Core SPECs (00-09, UI) are managed via `.gitignore` for IP protection.
> If they have been removed locally, treat the `SPEC:` references in code docstrings
> as historical pointers.
> **Note:** All SPECs are public and version-controlled in `docs/spec/`.

---

Expand Down Expand Up @@ -84,8 +82,8 @@ that combines LLM + GraphRAG + viral diffusion.
> into `21_SIMULATION_QUALITY_SPEC.md` on 2026-04-10. All original anchor IDs (`SQ-`,
> `EC-`, `BC-`, `CG-`, `RF-`, `HM-`, `MP-`) are preserved.
>
> Core engine SPECs (00-09) and UI SPECs (16 files) are `.gitignore`-protected for IP.
> The `SPEC: docs/spec/01_AGENT_SPEC.md#...` references in code docstrings are historical.
> All SPECs are now public. The `SPEC: docs/spec/01_AGENT_SPEC.md#...` references
> in code docstrings link directly to the checked-in files.

### SPEC Change → Test Auto-Generation Rule

Expand Down Expand Up @@ -387,12 +385,6 @@ Prophet/

- **⛔ Never implement without a SPEC** — if `docs/spec/` has no SPEC, write the SPEC first. Never generate code without a SPEC.
- **⛔ SPEC change requires test update** — whenever a Backend/Frontend SPEC changes, the relevant tests must be created or updated.
- **⛔ SPECs are private assets — never commit to public** — `docs/spec/`, `docs/init/`,
`docs/BUSINESS_REPORT.md`, `docs/MARKETING_STRATEGY.md`, and `docs/OASIS_vs_Prophet.md`
are the project's IP/moat and are listed in `.gitignore`. Keep these files local and
never push them to GitHub. When writing public documents like README.md, never quote
or link to SPEC documents or their contents — anyone with the SPEC alone can
reproduce Prophet.
- **⛔ No pip** — `uv` only
- **SLM fallback required** — every Tier 3 (Elite LLM) feature must have a Tier 1 (Mass SLM) fallback
- **Harness first** — write harness fixtures/mocks before the implementation
Expand Down
570 changes: 309 additions & 261 deletions README.md

Large diffs are not rendered by default.

78 changes: 54 additions & 24 deletions backend/app/api/simulations.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,13 @@ def _sim_id_to_uuid(simulation_id: str) -> UUID:


def _get_state_or_404(orchestrator: Any, simulation_id: str) -> Any:
"""Retrieve SimulationState from orchestrator or raise 404."""
"""Retrieve SimulationState from orchestrator or raise 404.

Used by agents.py, communities.py, llm_dashboard.py, network.py
for read-only GET endpoints that don't go through the service layer.
Simulation-mutating POST routes should use ``_svc_state_or_404``
instead so they depend only on ``SimulationService``.
"""
sim_uuid = _sim_id_to_uuid(simulation_id)
try:
return orchestrator.get_state(sim_uuid)
Expand All @@ -133,6 +139,38 @@ def _get_state_or_404(orchestrator: Any, simulation_id: str) -> Any:
)


def _svc_state_or_404(service: SimulationService, simulation_id: str) -> Any:
"""Retrieve SimulationState via the service layer or raise 404.

SPEC: docs/spec/20_CLEAN_ARCHITECTURE_SPEC.md#3.1

Clean Architecture version of ``_get_state_or_404``: the caller
depends on ``SimulationService`` only, never on the raw
``SimulationOrchestrator``. Used by mutation routes that need only
a state-fetch + 404 gate: start/step/pause/resume/stop, run-all,
engine-control, group-chat (create/add-message), interview.

Still on ``_get_state_or_404``: inject-event and replay (pending a
corresponding ``SimulationService`` method), plus read-only GETs
(compare, group-chat read) where the orchestrator dependency is
acceptable.
"""
sim_uuid = _sim_id_to_uuid(simulation_id)
try:
return service.get_state(sim_uuid)
except (ValueError, KeyError):
raise HTTPException(
status_code=404,
detail=ErrorResponse(
type="https://prophet.io/errors/not-found",
title="Simulation Not Found",
status=404,
detail=f"Simulation uuid={simulation_id} does not exist",
instance=f"/api/v1/simulations/{simulation_id}",
).model_dump(),
)


def _require_status(state: Any, *allowed: SimulationStatus) -> None:
"""Raise 409 if simulation is not in one of the allowed statuses."""
current = state.status
Expand Down Expand Up @@ -293,14 +331,13 @@ async def get_simulation(
@router.post("/{simulation_id}/start", response_model=StatusResponse)
async def start_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> StatusResponse:
"""Start the simulation.
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idstart
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.CONFIGURED, SimulationStatus.PAUSED)
now = datetime.now(timezone.utc)
await service.start(_sim_id_to_uuid(simulation_id), session=session)
Expand All @@ -310,14 +347,13 @@ async def start_simulation(
@router.post("/{simulation_id}/step", response_model=StepResultResponse)
async def step_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> StepResultResponse:
"""Execute exactly one step.
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idstep
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.RUNNING, SimulationStatus.PAUSED)

sim_uuid = _sim_id_to_uuid(simulation_id)
Expand Down Expand Up @@ -361,14 +397,13 @@ async def step_simulation(
@router.post("/{simulation_id}/run-all", response_model=RunAllResponse)
async def run_all_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> RunAllResponse:
"""Run all remaining steps to completion and return a report.
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idrun-all
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.CONFIGURED, SimulationStatus.RUNNING)
sim_uuid = _sim_id_to_uuid(simulation_id)

Expand Down Expand Up @@ -424,14 +459,13 @@ async def run_all_simulation(
@router.post("/{simulation_id}/pause", response_model=StatusResponse)
async def pause_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> StatusResponse:
"""Pause after current step completes.
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idpause
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.RUNNING)
await service.pause(_sim_id_to_uuid(simulation_id), session=session)
return StatusResponse(
Expand All @@ -442,14 +476,13 @@ async def pause_simulation(
@router.post("/{simulation_id}/resume", response_model=StatusResponse)
async def resume_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> StatusResponse:
"""Resume from paused state.
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idresume
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.PAUSED)
await service.resume(_sim_id_to_uuid(simulation_id), session=session)
return StatusResponse(status=SimulationStatus.RUNNING)
Expand All @@ -458,7 +491,6 @@ async def resume_simulation(
@router.post("/{simulation_id}/stop", response_model=StatusResponse)
async def stop_simulation(
simulation_id: str,
orchestrator: Any = Depends(get_orchestrator),
session: AsyncSession = Depends(get_session),
service: SimulationService = Depends(get_simulation_service),
) -> StatusResponse:
Expand All @@ -470,10 +502,8 @@ async def stop_simulation(
# Validate status transition if sim is in memory — preserve 409 behavior
# for unexpected states. DB-only sims skip this check.
try:
state = _get_state_or_404(orchestrator, simulation_id)
except HTTPException as e:
if e.status_code != 404:
raise
state = service.get_state(sim_uuid)
except (ValueError, KeyError):
state = None

if state is not None:
Expand Down Expand Up @@ -743,12 +773,12 @@ async def compare_simulations(
async def engine_control(
simulation_id: str,
body: EngineControlRequest,
orchestrator: Any = Depends(get_orchestrator),
service: SimulationService = Depends(get_simulation_service),
) -> EngineControlResponse:
"""Adjust SLM/LLM ratio at runtime (simulation must be PAUSED).
SPEC: docs/spec/06_API_SPEC.md#post-simulationssimulation_idengine-control
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
_require_status(state, SimulationStatus.PAUSED)

controller = EngineController()
Expand Down Expand Up @@ -861,12 +891,12 @@ def _get_group_chat_manager(simulation_id: str) -> GroupChatManager:
async def create_group_chat(
simulation_id: str,
body: dict,
orchestrator: Any = Depends(get_orchestrator),
service: SimulationService = Depends(get_simulation_service),
) -> dict:
"""Create a group chat session within a simulation.
SPEC: docs/spec/platform/13_SCALE_VALIDATION_SPEC.md#group-chat-action
"""
_get_state_or_404(orchestrator, simulation_id)
_svc_state_or_404(service, simulation_id)
mgr = _get_group_chat_manager(simulation_id)

member_ids = [UUID(m) for m in body.get("members", [])]
Expand Down Expand Up @@ -918,12 +948,12 @@ async def add_group_chat_message(
simulation_id: str,
group_id: str,
body: dict,
orchestrator: Any = Depends(get_orchestrator),
service: SimulationService = Depends(get_simulation_service),
) -> dict:
"""Add a message to a group chat.
SPEC: docs/spec/platform/13_SCALE_VALIDATION_SPEC.md#group-chat-action
"""
_get_state_or_404(orchestrator, simulation_id)
_svc_state_or_404(service, simulation_id)
mgr = _get_group_chat_manager(simulation_id)
try:
msg = mgr.add_message(
Expand Down Expand Up @@ -951,12 +981,12 @@ async def interview_agent(
simulation_id: str,
agent_id: str,
body: dict,
orchestrator: Any = Depends(get_orchestrator),
service: SimulationService = Depends(get_simulation_service),
) -> dict:
"""Interview an agent about their current state.
SPEC: docs/spec/platform/13_SCALE_VALIDATION_SPEC.md#interview-action
"""
state = _get_state_or_404(orchestrator, simulation_id)
state = _svc_state_or_404(service, simulation_id)
target_uuid = UUID(agent_id)

# Find the agent in the simulation
Expand Down
57 changes: 57 additions & 0 deletions backend/tests/test_06_api_simulations.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,63 @@ async def test_inject_unknown_type_400(self, client: AsyncClient, sim_id: str):
)
assert resp.status_code == 400

async def test_inject_roundtrip_content_reaches_agents(
self, client: AsyncClient, sim_id: str
):
"""End-to-end: verify the documented Inject Event flow.

This is the round-trip test behind the docstring claim:
1. POST /inject-event → 200 with event_id + effective_step
2. Event queued on state.injected_events with the typed content
visible in its `message` field
3. Next step consumes the queue (len → 0) and routes it through
perception so agents' exposure_count bumps
4. effective_step matches the step that actually processes it
"""
from app.api import deps as deps_mod

await client.post(f"/api/v1/simulations/{sim_id}/start")
orch = deps_mod.get_orchestrator()
from uuid import UUID
state = orch.get_state(UUID(sim_id))

step_before = state.current_step
exposure_before = sum(a.exposure_count for a in state.agents)

unique_content = "Battery explosion reported in 47 units — E2E marker"
resp = await client.post(
f"/api/v1/simulations/{sim_id}/inject-event",
json={
"event_type": "controversy",
"content": unique_content,
"controversy": 0.9,
},
)
# 1. API contract
assert resp.status_code == 200
data = resp.json()
assert "event_id" in data
assert data["effective_step"] == step_before + 1

# 2. Queued + content survives into the message
assert len(state.injected_events) == 1
queued = state.injected_events[0]
assert queued.event_type == "community_discussion" # controversy → mapped
assert unique_content in queued.message

# 3. Next step consumes the queue + agents perceive it
step_resp = await client.post(f"/api/v1/simulations/{sim_id}/step")
assert step_resp.status_code == 200
assert len(state.injected_events) == 0
exposure_after = sum(a.exposure_count for a in state.agents)
assert exposure_after > exposure_before, (
f"No agent saw the injected event "
f"(exposure {exposure_before} → {exposure_after})"
)

# 4. The consuming step matches effective_step
assert state.current_step == data["effective_step"]


@pytest.mark.phase6
class TestReplayAndCompare:
Expand Down
Loading
Loading