diff --git a/.env.example b/.env.example index 83f06e63..96371d66 100644 --- a/.env.example +++ b/.env.example @@ -194,6 +194,15 @@ LLM_PROVIDER=claude-cli # SIM_HORIZON_DEFAULT_MONTHS=12 # 问题中解析不出预测期限(确定性各档+LLM 兜底均失败)时的降级默认时域(月) # SIM_WORLD_DELTA=true # 世界时钟头部附带上一时段变化摘要(纯确定性拼装,无数值份额外泄;仅日历模式生效) # SIM_DECISION_CHANNEL_INBAND=true # 决策通道逐轮在环内引出承诺并推进 WorldState(仅日历模式;关闭=回退事后一次性通道) +# SIM_WORLD_MODEL=true # 反身世界模型(docs/handoff/reflexive-world-state/design.md):PREPARE 封印 world_model 块(研究/官方数据的状态变量、角色行动杠杆类别、带价关系), +# # 运行期每轮每个决策角色一次结构化决策(选项菜单由其角色契约与当前世界生成,声明以帖子发布),双平台对齐后提交唯一一次世界更新 +# # (机械内核 + 有界仲裁 LLM),下一轮所有 agent 观察到变化后的世界。false=回退旧的环内情景份额演化(逐字节不变);仅日历模式生效 +# SIM_WORLD_ARBITER=true # 世界更新中的仲裁 LLM 调用(裁决争议行动、移动状态变量、产生涌现事件/延迟效应);false=仅机械推进(容量/承诺/关系/到期效应/日程事件) +# SIM_WORLD_BARRIER_TIMEOUT_S=1800 # 平台等待另一平台交付本轮的秒数;超时按已交付内容提交并把缺席平台标为 stalled +# SIM_WORLD_DECISION_MAX_ACTORS=24 # 每轮决策角色上限(principal 优先,其后按影响力取活跃具名角色;受众填充不决策) +# SIM_WORLD_DECISION_CONCURRENCY=6 # 每轮并发的决策 LLM 调用数 +# SIM_WORLD_MAX_VARIABLES=24 # 种子状态变量上限(先取定量事实,再取官方序列最新观测) +# SIM_WORLD_MAX_CHANGE_RATIO=0.5 # 仲裁对数值变量的单轮变动钳制(相对当前量级的比例;不变式 I-2) # WORLDSTATE_ENTROPY_MIX=true # WorldState.step 熵地板:按时段天数向种子基率先验混合,防长时域份额锁死(仅日历模式生效) # OASIS_MAX_AGENTS=80 # 智能体上限;超过按(匹配actor,影响力,邻边数)排序保留,始终保留研究 actor(T3.13) # ACTOR_CAST_MAX=20 # 主角色阵容硬上限(actor-cast discipline):只保留决策/行动会因果性影响预测结果的 main actors; @@ -646,6 +655,7 @@ RESEARCH_ALLOW_LOW_QUALITY_SOURCES=false # REPORT_VIZ_PLOTLYJS_INLINE=false # i1 回退旋钮:true 恢复逐图内联 plotly.min.js(每图 ~4.86MB);默认 directory 模式 + 服务时内联(每图 ~8.4KB) # REPORT_SECTION_RETRY_MAX=2 # RPT-2 每章写失败占位符前的重试次数;RQ-1 1→2;0=旧的无重试 # REPORT_SECTION_RETRY_BACKOFF_S=8.0 # RPT-2 章节重试基础退避秒数(按次线性,封顶 60s) +# REPORT_FILTER_SAFE_LADDER=true # FILTER-SAFE 章节被提供方内容审查(HTTP 400 code 1301 / 422)拒绝后的处置:采纳仅因工具调用下限被退回且通过污染检测的完整草稿;无草稿时不退避、立即升级重试(L1 机构称谓写作指令+工具下限 1+检索回包截断 6k 字;L2 无检索工具、仅据背景档案与已完成章节成文)。走过该处置的章节记入 meta.json 的 filter_safe_sections(0/1/2)。false=旧的原样退避重试 # REPORT_CRITIQUE_BEFORE_PROSE=true # RPT-3 自校准/premortem 移到写散文**之前**,钉定骨架==交付 forecast.json(仅 REPORT_FORECAST_SELF_CRITIQUE 开启时生效) # FORECAST_BINARY_CONTRARIAN=true # RPT-4 二元预测提示词加入逆向框架规则 + stdev<0.12 或全部 p>0.5 时一轮有界(0.05-0.35)低概率重构 # FORECAST_SIM_SENSITIVITY=true # XRUN-1(b) 二元预测抽取注入模拟定量信号包,每条概率须说明相对 base_rate_anchor 的移动及具名模拟信号来源 diff --git a/README.md b/README.md index 46953f54..5b4ce7fa 100644 --- a/README.md +++ b/README.md @@ -1000,11 +1000,13 @@ sequenceDiagram - On Twitter: create post, like, repost, quote, comment, follow, search posts, trend, do nothing. - On Reddit: create post, comment, like or dislike a post, like or dislike a comment, search posts, search users, trend, refresh, follow, mute, do nothing. - Recommendations come from a model-free recommender, which makes no extra LLM calls. -- **World state.** A shared WorldState advances once per paired round. After both platforms have delivered round *i*: - - one batched LLM call reads the agents' commitments; - - the state steps forward, with inertia scaled to the calendar unit and a base-rate entropy floor. +- **Reflexive world state** (`SIM_WORLD_MODEL=true`, calendar mode). Every round is a cycle of observe → decide → act → world update, shared by both platforms: + - *Observe.* Each active agent receives the committed WORLD STATE brief: tracked facts (seeded from the dossier's quantitative facts and the official-data snapshot, each with unit, as-of date and provenance), last period's moves and their consequences, alliance and rivalry shifts, and open commitments. Scenario probabilities are never shown to agents. + - *Decide.* Each deciding actor (principals first, then active named actors, capped by `SIM_WORLD_DECISION_MAX_ACTORS`) makes ONE structured decision from a menu grounded in its sealed role brief and the current world: responses to moves aimed at it, its documented plans, its agency class's catalogued levers (government, regulator, legislature, central bank, firm, union, NGO, coalition, individual leader…) filtered by remaining capacity, and `hold_position`. The answer carries targets, magnitude, a public statement, the exact terms when the move is an offer, a dated commitment, alliance moves and the scenario the move pushes toward. An offer (talks, alliance, concession, agreement, deal) binds nobody until the recipient accepts, counters, rejects or defers it from its own menu; unanswered offers lapse after two periods. Invalid or failed answers degrade to a typed hold, never to silence. + - *Act.* The statement is published as that actor's post on both platforms (one decision per actor per round, shared across platforms) and the reaction step lets everybody reply. + - *World update.* When both platforms have delivered the round, ONE update runs: the deterministic kernel consumes capacity, settles offers (acceptance binds both parties with obligations; a lapsed obligation or a withdrawal is a breach that costs reputation and the relationship), registers commitments (honoured by acting when due, lapsing otherwise), nudges relationships, fires due delayed effects and research events; then a single bounded arbiter call resolves contested moves, moves state variables (clamped by `SIM_WORLD_MAX_CHANGE_RATIO`), emits emergent events and delayed effects. The scenario-share WorldState steps from the actors' scenario commitments with the calendar inertia and entropy floor. A platform never starts round *i+1* before update *i* is committed. - The trajectory is saved to `world_state_trajectory.json` and charted in the report. + `world_state.json` (latest world, with its offers, the seed's modelling gaps and per-round behaviour diagnostics), `world_state_history.jsonl`, `world_actions.jsonl`, `world_state_trajectory.json` and `decisions.jsonl` are saved; the report's diagnostic pack renders the evolution, offers, gaps and a world-variables chart, and the dashboard's simulation tab shows a live World State panel whose period strip opens any committed round — the feed beside it then shows that period's posts and replies only. With `SIM_WORLD_MODEL=false` the legacy in-band scenario evolution runs unchanged. - **Monitoring and completion.** - A monitor thread in the backend tails the action logs every 2 s into `run_state.json`. - The pipeline polls every 5 s and stops a run that makes no round progress for 30 minutes. @@ -1019,6 +1021,7 @@ Output, in `backend/uploads/simulations//`: - `simulation_config.json` and its seal; - `twitter/actions.jsonl` and `reddit/actions.jsonl`, plus the two platform SQLite databases; - `world_state_trajectory.json` and `decisions.jsonl`; +- `world_state.json`, `world_state_history.jsonl` and `world_actions.jsonl` (reflexive world model); - `run_summary.json` and `simulation.log`. `SIM_TEMPORAL_MODE=hours` restores the legacy news-cycle mode, in which an LLM picks 24–168 simulated hours. @@ -1491,6 +1494,8 @@ Provider credentials are optional environment inputs, passed only to the relevan | Setting | Default | Purpose | |---|---|---| | `SIM_TEMPORAL_MODE` | `calendar` | `calendar` or the legacy `hours` | +| `SIM_WORLD_MODEL` · `SIM_WORLD_ARBITER` | `true` · `true` | Reflexive world model (structured actor decisions, one world update per round) and its arbiter call; `false` keeps the legacy scenario-share evolution | +| `SIM_WORLD_DECISION_MAX_ACTORS` · `SIM_WORLD_MAX_CHANGE_RATIO` | `24` · `0.5` | Deciding actors per round; per-round clamp on arbiter numeric changes | | `SIM_CALENDAR_TARGET_MAX_ROUNDS` · `OASIS_DEFAULT_MAX_ROUNDS` | `36` · `36` | Round budget used to choose the calendar unit | | `ACTOR_CAST_MAX` | `20` | Maximum researched cast size | | `SIM_AUDIENCE_AGENTS` | `0` | Extra rule-generated audience agents | diff --git a/README.zh-CN.md b/README.zh-CN.md index a05a16ff..a69736c6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1000,11 +1000,13 @@ sequenceDiagram - 在 Twitter 上:发帖、点赞、转发、引用、评论、关注、搜索帖子、查看趋势、什么都不做。 - 在 Reddit 上:发帖、评论、给帖子点赞或点踩、给评论点赞或点踩、搜索帖子、搜索用户、查看趋势、刷新、关注、屏蔽、什么都不做。 - 信息流由免模型的推荐器生成,不产生额外的 LLM 调用。 -- **世界态。** 共享的 WorldState 每对齐一轮前进一次。两个平台都提交第 *i* 轮之后: - - 一次批量 LLM 调用读取智能体的承诺; - - 世界态随之推进一步,其惯性按日历单位缩放,并设有基率熵下限。 +- **反身世界态**(`SIM_WORLD_MODEL=true`,日历模式)。每一轮都是观察 → 决策 → 行动 → 世界更新的循环,两个平台共享: + - *观察。* 每个活跃智能体收到已提交的「世界状态」简报:追踪事实(由档案定量事实与官方数据快照播种,带单位、截止日与来源)、上一时段的行动及其后果、结盟与对立的变动、以及未兑现的承诺。情景概率绝不展示给智能体。 + - *决策。* 每个决策角色(主角优先,其后为活跃具名角色,受 `SIM_WORLD_DECISION_MAX_ACTORS` 约束)从一份基于其封印角色简报与当前世界生成的菜单中做出**一次**结构化决策:对针对自己的行动的回应、档案记载的计划、其行动杠杆类别(政府、监管机构、立法机关、央行、企业、工会、NGO、联盟、个人领导者……)在剩余容量内的目录动作,以及 `hold_position`。答案携带目标、力度、公开声明、当行动是要约时的确切条款、带期限的承诺、结盟动作与该行动所推向的情景。要约(谈判、结盟、让步、协议、交易)在对方从自己的菜单中接受、反要约、拒绝或暂缓之前对任何一方都不具约束力;两个时段无人应答的要约自动失效。无效或失败的回答降级为有类型标注的观望,而非沉默。 + - *行动。* 声明作为该角色的帖子在两个平台发布(每轮每个角色一次决策,跨平台共享),回应步骤让所有人回复。 + - *世界更新。* 两个平台都交付本轮后,只运行**一次**更新:确定性内核消耗容量、结算要约(接受即对双方产生义务;义务失效或单方退出即为违约,损及声誉与关系)、登记承诺(到期时以行动兑现,否则失效)、推移关系、触发到期的延迟效应与研究事件;随后一次有界的仲裁调用裁决争议行动、移动状态变量(受 `SIM_WORLD_MAX_CHANGE_RATIO` 钳制)、产生涌现事件与延迟效应。情景份额 WorldState 依据角色的情景承诺、按日历惯性与熵下限推进。任一平台在第 *i* 轮更新提交前绝不开始第 *i+1* 轮。 - 轨迹保存在 `world_state_trajectory.json`,并在报告中绘成图表。 + 保存 `world_state.json`(最新世界,含其要约、种子的建模缺口与逐时段行为诊断)、`world_state_history.jsonl`、`world_actions.jsonl`、`world_state_trajectory.json` 与 `decisions.jsonl`;报告的诊断信号包渲染世界演化、要约、缺口与世界变量图,仪表盘的模拟页显示实时「世界状态」面板,其时段条可打开任一已提交轮次——旁边的信息流随之只显示该时段的帖子与回复。`SIM_WORLD_MODEL=false` 时旧的环内情景演化原样运行。 - **监控与完成判定。** - 后端的一个监控线程每 2 秒把行为日志汇总进 `run_state.json`。 - 管线每 5 秒轮询一次;连续 30 分钟没有回合进展的模拟会被停止。 @@ -1019,6 +1021,7 @@ sequenceDiagram - `simulation_config.json` 及其封印; - `twitter/actions.jsonl` 与 `reddit/actions.jsonl`,以及两个平台的 SQLite 数据库; - `world_state_trajectory.json` 与 `decisions.jsonl`; +- `world_state.json`、`world_state_history.jsonl` 与 `world_actions.jsonl`(反身世界模型); - `run_summary.json` 与 `simulation.log`。 设置 `SIM_TEMPORAL_MODE=hours` 可恢复旧式的新闻周期模式,由 LLM 选定 24–168 个模拟小时。 @@ -1491,6 +1494,8 @@ npm run dev # 另一种方式:在前台同时运行两者(Ctrl | 设置 | 默认值 | 作用 | |---|---|---| | `SIM_TEMPORAL_MODE` | `calendar` | `calendar`,或旧式的 `hours` | +| `SIM_WORLD_MODEL` · `SIM_WORLD_ARBITER` | `true` · `true` | 反身世界模型(结构化角色决策、每轮一次世界更新)及其仲裁调用;`false` 保留旧的情景份额演化 | +| `SIM_WORLD_DECISION_MAX_ACTORS` · `SIM_WORLD_MAX_CHANGE_RATIO` | `24` · `0.5` | 每轮决策角色数;仲裁数值变动的单轮钳制 | | `SIM_CALENDAR_TARGET_MAX_ROUNDS` · `OASIS_DEFAULT_MAX_ROUNDS` | `36` · `36` | 选择日历单位时使用的回合预算 | | `ACTOR_CAST_MAX` | `20` | 研究阵容的最大规模 | | `SIM_AUDIENCE_AGENTS` | `0` | 额外按规则生成的受众智能体数量 | diff --git a/agent-progress.txt b/agent-progress.txt index ef7ec12e..dc1fe776 100644 --- a/agent-progress.txt +++ b/agent-progress.txt @@ -379,3 +379,88 @@ This repository implements a six-stage forecast workflow: DeerFlow 2 research, o - **Follow-ups:** - Live validation (a paid run; needs owner approval): confirm the agents' notes and reports follow the routed methods. `feature_research_method_skills` stays `passes: false` until then. - `_BASE_RATE_RE` treats any 历史 as a base-rate KIQ; narrow it only if live runs show casual uses. + +### 2026-10-16T22:45+08:00 (CST) — branch `claude-reflexive-world-state-model` — reflexive world-state simulation + +- **User request (2026-10-16 21:34 CST):** make the multi-agent simulation realistic, dynamic and reflexive: an explicit world state every round that changes in response to the agents' actions and that agents observe and respond to; realistic option sets motivated by goals, incentives, alliances, competition and constraints; new agent steps (world-state update); ontology improvements; new branch off main. +- **Work (design and ledger in `docs/handoff/reflexive-world-state/`):** + - Kernel `backend/app/services/world_model.py`: state variables with causal history and change-ratio clamp, actor states (capacity, plans, red lines, commitments), valenced relationships, delayed effects with a seeded RNG, events, public/private briefs (no scenario shares ever), JSON round-trip. + - Decision step `agent_decision.py`: 18 agency classes with word-boundary inference, an action catalogue per class, world-grounded option menus (responses → documented plans → class levers → hold), a JSON decision contract with typed `invalid`/`failed` holds, memory notes. + - World update `world_update.py`: deterministic step, arbiter prompt/validation/application, agent-facing digest, round record. + - Runner `backend/scripts/run_parallel_simulation.py`: `_ReflexiveWorldCoordinator` (asyncio barrier so no platform starts round r+1 before update r commits; one shared decision per actor per round; statements published as tagged ManualAction posts; USER memory notes; resume guards), loop wiring in both platforms, WORLD STATE section in the WORLD CLOCK note; `action_logger.log_world_update`. + - PREPARE `world_model_seed.py` + `simulation_config_generator.py` (`world_model` block sealed into the config; `AgentActivityConfig.agency_class/archetype/outcome_power`), `ontology_generator.py` (`agency_class`/`levers` metadata + normalisation), config flags `SIM_WORLD_MODEL`, `SIM_WORLD_ARBITER`, `SIM_WORLD_BARRIER_TIMEOUT_S`, `SIM_WORLD_DECISION_MAX_ACTORS`, `SIM_WORLD_DECISION_CONCURRENCY`, `SIM_WORLD_MAX_VARIABLES`, `SIM_WORLD_MAX_CHANGE_RATIO`. + - Downstream: run-state `options.world_model`, `run_summary.world_model`, `GET /api/simulation//world-state`, report `_world_evolution_block` + `world_variables` chart (EN/ZH titles), orchestrator summary, frontend `WorldStatePanel.vue` + `utils/worldState.js`, `.env.example`, README EN/ZH (line-aligned). +- **Verification:** + - New offline tests: world_model 18, agent_decision 27, world_update 6, world_model_seed 9, world_model_downstream 10, world_model_loop 6 (real round loop with FakeEnv + scripted LLM: reflexivity, two-platform barrier, arbiter failure, off switches, resume). Legacy calendar loop 7 and reaction phase 20 unchanged and green. + - Frontend: 145/145 unit tests, build OK. ruff (F,E9), `check_env_drift --strict`, compileall and `init.sh` pass. + - Full backend suite: only the 2 pre-existing `test_export_demo_site_data` demo-asset failures (deleted docs/demos files in this working tree) remain; totals recorded in the handoff after the final rerun. +- **Follow-ups:** + - Live validation run (paid; owner approval) before `feature_reflexive_world_state_model` can pass. + - Codex is drafting a plan for the same topic on `codex/reflexive-world-simulation` (separate worktree); do not merge blindly. + +### 2026-10-17T01:45+08:00 (CST) — branch `claude-reflexive-world-state-model` — live validation of the reflexive world-state simulation + +- **Task (from the parent session, owner-approved paid run 2026-10-17 00:01 CST):** validate the reflexive world-state simulation with a real LLM run and decide `feature_reflexive_world_state_model`. +- **Run:** `pipe_d569f211eb14` → `sim_d6ebcc77e22d`, glm-5.3 (default provider), depth standard, max_rounds 12, English; question "Forecast the state of US–China trade and technology restrictions as of 30 June 2027 …"; calendar unit month × 9 rounds, 7 agents. Wall 78 min (research 15, graph 32, sim 11, report 18); 1,084 calls, 8.77 M input / 0.43 M output tokens, est. $6.20; simulation alone 218 calls / 982.5 k tokens / 631 s. +- **World-model evidence (details in `docs/handoff/reflexive-world-state/handoff.md` §7 "Live run"):** PREPARE sealed a `world_model` block (24 research variables, 7 classed actors, 7 relationships); 9 world rounds committed, arbiter applied in 7 and failed in 2 (provider content filter 400/1301, rounds committed mechanically); 50 structured decisions, all valid, 2 holds, 12 distinct option ids varying by agency class; one decision per actor per round, published identically on Twitter and Reddit (48 tagged posts each, 9 `world_update` rows each); 11 fact changes, all with causes, none outside the 0.5 clamp (US tariff on Chinese imports 35.2 → 24.0 as the simulated deal landed); round wall time 39–140 s against the 1800 s barrier; round-2+ statements react to earlier moves and events (Affiliates Rule reinstatement → MOFCOM graphite licence review → Greer's verification demand → …). Dashboard World State panel rendered live and after the run; `run_summary.world_model` present; report `world_variables` chart generated and registered; `_world_evolution_block` renders from the live state. +- **Decision:** `feature_reflexive_world_state_model.passes = true` (realism review by the agent; owner may re-review the statements in `world_actions.jsonl`). +- **Not caused by this branch:** the report stage failed its final publish gate — the binary-forecast extraction call hit the Zhipu content filter (binary_count 0) and one long table cell was flagged as truncated; the same filter also skipped 2 graph chunks and 2 market-relevance scorings. All report artifacts exist under `backend/uploads/reports/report_7dfd24d443d0`. +- **Follow-ups (handoff §9):** persist the provider error code in `arbiter_problems` and use the fallback provider on content-filter 422; language check in decision validation (1/50 statements came out in Chinese); blank kind-named units on arbiter-created facts ("yes boolean"); world-variables chart should start at the seed value and keep one point per period; coordinator log lines are print-buffered in the idle runner and never reach `simulation.log`; the seed finds no plans/red lines in v3 `actors.json` (only goals/incentives/constraints). + +### 2026-10-17T03:40+08:00 (CST) — branch `claude-reflexive-world-state-model` — resume the failed live run to completion (report stage) + +- **Task (owner, 02:28 CST):** "continue the failed run to make sure it runs to completion. fix any issues" — `pipe_d569f211eb14` had failed at the report stage. +- **Resume #1 (02:41–03:02):** on the content-filter-resilient binary extraction (commit abb5ddc) the gate no longer complained about `binary_count` or truncated cells, but refused the report for one generation-failed chapter ("The Actor Architecture: Who Actually Decides the Outcome"), content-filtered (HTTP 400 code 1301) on 3 + 3 attempts. +- **Root cause (agent_log.jsonl):** a complete 13–15k-char Final Answer passed the filter but was rejected by the ReAct loop for 1 < min tool calls; the extra retrieval dumps (48k + 22k chars) then tripped the provider's input-side filter; retries replayed the identical request (LLM cache), and the resurrection `quick_search` carried flat args → empty query. +- **Live probes (8 calls, coding plan):** brief naming Trump/Xi passes; institutional-register directive passes; rewriting names as offices ("China's top leader") is itself filtered → name redaction rejected as a design. +- **Fix:** `backend/app/services/report_filter_safe.py` (new) + `report_agent.py`: accept the floor-rejected clean draft when a filter interrupts the attempt (recorded as `filter_safe_sections`=0); otherwise a no-backoff ladder L1 (register directive, tool floor 1, observations capped 6k) → L2 (no tools); no native→ReAct fallback on a filter; parser accepts flat/OpenAI-style tool args; `REPORT_FILTER_SAFE_LADDER` off switch (Config + `.env.example`); `Report.filter_safe_sections` disclosure in `meta.json`. +- **Tests:** `tests/test_report_filter_safe.py` 18 new, all green with the audit-fix suite (46) and the report regression subset (102); full suite on the final code: 2 pre-existing demo-asset failures / 6262 passed / 22 skipped / 11 xfailed (320 s); ruff, env-drift (strict), compileall OK. +- **Resume #2:** backend restarted (pid 90076), pipeline resumed 03:34, report `report_0f2e07d62a34` — outcome appended below when it finishes. +- **Outcome (03:57 CST):** `pipe_d569f211eb14` status `completed`; `report_0f2e07d62a34` published (gate passed, no hard/epistemic issues, 10 chapters, 3 scenarios / 17 binaries, confidence medium). `filter_safe_sections`: Forecast Table → 0 (draft accepted), The Actors → 1 (register directive, 1 tool call), Legal Cliff Calendar → 2 (no tools); 21 code-1301 events in the run, none fatal. The optional Chinese translation was rejected by its integrity audit (16 translation calls failed on provider errors) — follow-up recorded in the handoff §9. + +### 2026-10-17T02:45+08:00 (CST) — branch `claude/friendly-murdock-e4a17e` (worktree, from `claude-reflexive-world-state-model` tip) — harden the world update and decision steps against provider failures + +- **Task (from the live-validation session, follow-ups of `sim_d6ebcc77e22d`):** make the arbiter failure record precise and route content-filter rejections through the LLM client's fallback provider; reject public statements written in the wrong script for the run language with a typed status; never render a kind name ("boolean", "date") as a unit. +- **Finding:** `LLMClient.chat()` already retries every non-retryable error (including the Zhipu HTTP 400 / code 1301 content filter) once on `LLM_FALLBACK_PROVIDER`; the live arbiter path went through it. Nothing took over because no fallback provider is configured in this deployment (backend `.env` has no `LLM_FALLBACK_*`), and the record only said `llm error: BadRequestError`. No second attempt was added — the fix is precise records plus the failover outcome, pinned end-to-end through the real client. +- **Work:** + - `backend/app/utils/llm_errors.py` (new, pure): `describe_llm_error` / `llm_error_details` — exception class, HTTP status, provider code (`body.error.code`, MiniMax `base_resp`, SDK `code`, or the serialised body), content-filter / quota / auth classification, bounded snippet, failover note. `llm_client._is_content_filter` and `_err_brief` now use it (Zhipu 1301 counts toward the 422 breaker). + - `LLMClient._try_fallback` attaches the failover outcome to the primary error (`BaseException.add_note`): `fallback: not configured (LLM_FALLBACK_PROVIDER unset)`, `fallback

: skipped (...)`, `fallback

: failed ()`; the circuit-breaker path carries the note too. + - `world_update.run_arbiter`: `arbiter_problems` = `llm error: BadRequestError (HTTP 400, provider code 1301, content filter; fallback: ...): Error code: 400 - {...}` (snippet ≤ 200 chars); `apply error` records likewise; `record["decisions"]` counts `language_mismatch`. + - `agent_decision`: `AgentDecision.problems` (serialised to `world_actions.jsonl`), `elicit_decision` records provider errors the same way and takes `language`; `expected_script` / `text_script` / `statement_matches_language` (CJK ideographs/kana/hangul vs Latin letters, one CJK char ≈ three Latin letters, same weighting as the run-language inference); `parse_decision(language=...)` rejects a non-hold move whose model-written statement is in the wrong script → typed hold `status="language_mismatch"` with the rejected text in `problems`; the actor then posts organically (the loop already treats non-ok decisions that way). Coordinator passes `self._language` and records exceptions in `problems`. + - Units: `world_model.is_kind_name_unit`; `validate_arbiter_update` blanks the unit when the kind is non-numeric or the unit equals the kind name / a type word; `StateVariable` keeps units on numeric facts only (also on load and when a numeric fact becomes text); `format_value` never prints a kind name; report `_world_evolution_block._fmt` and the frontend `formatWorldValue` guard persisted runs the same way. +- **Verification:** + - New/updated offline tests: `test_world_update.py` (precise record; applied round via the fallback provider with exactly one primary attempt + one fallback retry; both-fail and unconfigured records; unit blanking; briefs/digest/arbiter prompt never print "yes boolean"), `test_agent_decision.py` (script heuristic, language rejection with typed status and `problems`, Chinese-name quotes kept, synthesised statements never checked, provider-error record, fallback recovery), `test_world_model_loop.py` (content-filter arbiter rejection recorded precisely across 3 committed rounds; Chinese statement in an English run → `language_mismatch` row, no decision post, organic post, brief shows the typed non-move), `test_world_model.py` (kernel unit normalisation), `test_llm_errors.py` (new), `frontend/tests/worldState.test.mjs`. + - Targeted suites green: world_update 11, agent_decision 59, world_model_loop 8, world_model 19, llm_errors 4; neighbours (llm_fallback_model, audit_fixes_infra, calendar_round_loop, world_model_downstream/seed, decision_channel, worldstate, canonical brief denials, world_delta) 137 passed. Frontend `node --test`: 145/145 (dependencies linked from the main checkout; the worktree has no `node_modules`). + - ruff F,E9: clean on every new/changed line; the only findings in `report_agent.py` / `run_parallel_simulation.py` are identical to HEAD (pre-existing unused imports / placeholder-less f-strings). compileall (app, scripts, tests) OK. + - Full backend suite: 6222 passed, 41 skipped, 11 xfailed, 0 failed, 19 errors (counted from the `-q` progress markers; the project's `addopts=-q` doubled to `-qq` hides the summary line). All 19 errors are setup errors in `tests/test_legacy_official_data.py` that read the ignored `deer-flow/` checkout from the repo root, which this worktree does not have; with it linked temporarily the module passes 101/101, so the suite is green apart from that environmental gap. +- **Follow-ups:** set `LLM_FALLBACK_PROVIDER` / `LLM_FALLBACK_MODEL` in the backend `.env` so the failover has somewhere to go on the next live run; the remaining §9 items of `docs/handoff/reflexive-world-state/handoff.md` (chart seed point, coordinator logging, seed plans/red lines, round selector) are owned by the sibling sessions. +### 2026-10-17T02:45+08:00 (CST) — branch `claude/quirky-lamport-99f71d` (off `claude-reflexive-world-state-model`) — close the three live-validation gaps + +- **Task (from the live-validation session):** (1) the seed read no plans or red lines from research engine v3 `actors.json`; (2) the report's world-variables chart started each series at its first change and drew intra-period duplicates; (3) the coordinator's per-round lines were missing from `simulation.log`. +- **Work:** + - `world_model_seed.py`: when a dossier row carries none of the legacy plan / red-line keys, `derive_v3_plans` promotes the actor's own stated commitments (`stated_vs_revealed` stated clause with a commitment marker), action-phrased goals (leading action verb; maintain-type verbs, hedges and refusals never) and forward commitments in `memory`; `derive_v3_red_lines` promotes constraints and statements phrased as non-negotiables. The revealed half, incentives, vulnerabilities and worldview are never read; explicit keys still win; caps unchanged. On the live dossier `pipe_d569f211eb14` this yields plans for 8 of 11 actors and, faithfully, no red lines (its constraints are external pressures). The v3 engine contract was not changed. + - `report_visualizer.py`: new pure `world_variable_series` — seed at the as-of date (index 100), the last value of each committed period, carried forward when unchanged, arbiter-created variables starting at their first period, categorical x axis in run order; `build_world_variables_html` draws it. `report_7dfd24d443d0/charts/world_variables.{html,png}` regenerated in place. + - `simulation.log`: root cause is two non-appending writers on one file — the backend hands the runner subprocess the file as stdout/stderr (`w`) while the runner's `SimulationLogManager` opens it with FileHandler mode `w`; each overwrote the other at its private offset (torn UTF-8, `grep` treating the file as binary, the print copies of rounds 7–9 flushed only at exit). Fix: `SimulationRunner._open_main_log` (truncate, then append-only), FileHandler mode `a`, no second truncation and no console echo when stderr already is the log file, runner stdout line-buffered on every platform (the old reconfigure block was Windows-only). +- **Verification:** new tests `test_v3_plans_come_from_stated_commitments_and_action_phrased_goals`, `test_v3_red_lines_come_from_non_negotiable_wording_only`, `test_build_seed_from_v3_dossier_yields_plans_and_red_lines` (seed 11 passed), `test_world_variable_series_seed_at_as_of_and_one_point_per_period`, `test_world_variables_chart_starts_at_seed_without_duplicates` (downstream 12 passed), `test_world_round_lines_reach_simulation_log` (loop 7 passed; against the old writers it loses the round-1 record). Neighbouring runner/logger suites 174 passed; hours mode untouched (the seed block is only sealed in calendar mode, stdout buffering changes timing only). ruff F,E9 on the changed files: 16 findings, all pre-existing at HEAD on untouched lines; compileall of app/scripts/tests clean. Full backend suite result recorded in the commit message. +- **Follow-ups:** platform `log_info` still prints every message it also logs, so each record appears twice in `simulation.log`; the coordinator's lines carry the `[Twitter]` prefix of whichever platform built it. +### 2026-10-17T02:35+08:00 (CST) — branch `claude/wizardly-shamir-b82bf9` (off `claude-reflexive-world-state-model`) — World State panel: selectable period strip + +- **Request (side session started by the live-validation session, 2026-10-17 02:09 CST):** make the period strip under the dashboard's World State panel a real round selector, label past periods, keep the latest round selected by default with a one-click return, never reset a selection on live refresh unless the round vanished, give applied ticks a visible style (red for failed/invalid, bold for the current one), add an accessible label, extend the API conservatively if a field is missing, and verify on `sim_d6ebcc77e22d`. +- **Work:** + - `GET /api/simulation//world-state` (`backend/app/api/simulation.py`): every history row now carries bounded `arbiter_problems` (≤12 × 200 chars), `error` when the update itself failed, and the round's agent-facing `digest` joined from `world_digest.jsonl` (≤2000 chars). Both JSONL artifacts go through one `_bounded_jsonl_rows` reader: regular file only, 8 MB cap, malformed lines skipped. The quantitative shares never travel. + - `frontend/src/utils/worldState.js`: `summarizeWorldState(data, selectedRound)` describes the selected round (headline summary, narrative, arbiter status and problems, decision counts, moves, consequences, changed/added facts, relationship changes, digest) while the tracked variables and open commitments keep describing the latest world; new pure helpers `latestHistoryRound`, `resolveSelectedRound`, `nextSelectedRound`, `headlineSummary`, `decisionsSummary`; rounds older than the recent-event window fall back to the history row's deterministic lists. + - `frontend/src/components/research/WorldStatePanel.vue`: ticks are toggle buttons (`role=group`, `aria-pressed`, descriptive `aria-label`, headline tooltip); a selected past round is labelled "past period · round r / N" with a "back to latest" button; the latest round is selected by default; a live refresh keeps the selection unless the round disappeared (rerun, rotated artifacts) or the simulation changed; tick hue = arbiter verdict (green tint applied, red failed/invalid, amber skipped, grey otherwise), solid fill = selected, bold + ring = latest; the digest sits in a collapsible block (open when the round has no narrative); 48 history rows per poll. + - README EN/ZH line 1009 mention the selector (line counts unchanged). +- **Verification:** + - `backend/tests/test_world_model_downstream.py`: 11 passed, incl. the new `test_world_state_api_history_rows_carry_problems_and_digests` (problems/error/digest, bounds, symlinked digest refused, non-list problems tolerated). ruff F,E9 clean. + - Frontend: `node --test tests/*.test.mjs` 156 passed (`worldState.test.mjs` extended with the selection helpers and past-round view; new `worldStatePanel.test.mjs` drives the real SFC through Vue's server renderer: default selection, past-round view, failed round, refresh keeps the selection, rotation resets it, simulation change resets it, Chinese labels); `npm run build` OK. + - Live: this worktree's backend on :5011 (`DRF_TEST_PROCESS=1` so it neither reclaims the sibling backend's idle runner nor loads `.env`; `backend/uploads` linked to the primary checkout for the check only) → History → US–China pipeline → Simulation tab: 9 ticks with labels; 2026-12 shows its narrative, the `unknown variable` problem, "5 decisions · 5 ok", 2 changed + 2 new facts, deterministic moves and effects (outside the 40-event window), the digest; 2027-02 shows `arbiter: failed`, the BadRequestError problem, no narrative, the digest expanded; "back to latest" restores the four-card live view; Tab + Enter selects the next tick; the Refresh button and the 15 s poll (13 requests) kept round 7 selected. +- **Notes / follow-ups:** the frontend has no dark theme (`tokens.css` is light-only), so the ticks rely on inherited text colour and translucent tints that stay legible on either surface rather than on a theme override. Arbiter-created facts still print their kind name as a unit ("announced, not signed text"); that belongs to the seed/kernel follow-up already listed in `docs/handoff/reflexive-world-state/handoff.md` §9. Nothing pushed. + +### 2026-10-17T20:30+08:00 (CST) — branch `claude-reflexive-world-state-model` — Codex world-state study + merge, per-period simulation view + +- **Request (19:34 CST):** study `codex/reflexive-world-simulation`, merge the best of both world-state implementations, merge to main; make the simulation tab's period tabs selectable with per-period world state and feed. +- **Done:** merged the three follow-up branches (friendly-murdock, quirky-lamport, wizardly-shamir); studied the Codex `world-v1` engine (docs + read-only code summary) and ported its consent rule (`world_model.Interaction`: offers → accept/counter/reject/defer → obligations → breach; decision `terms`), seed modelling gaps (`world_model_seed.seed_gaps`) and behaviour diagnostics (`world_update.behaviour_metrics`), exposed in the run summary, `/world-state`, the panel (offers card, gap/behaviour badges) and the report block; per-period feed (`services/feed_rounds.py`, `/feed-rounds`, `round=` on `/posts` `/comments`, panel `round` prop + `update:selectedRound`, feed follows the selection) live-checked on the Brent run; README EN/ZH and design.md §2.10 updated. Not merged: the Codex engine itself (separate opt-in engine replacing OASIS; failed its own live gate) — reasons in design.md §2.10. +- **Verification:** isolated-worktree full suite on this branch's hunks 6287 passed / 1 failed (zero-seed chart regression, fixed) / 19 errors (worktree lacks the untracked deer-flow tree); frontend 163 passed; build OK; gates OK. A concurrent process was editing this checkout (price-history chart removal and related files); its changes were left uncommitted. +- **Ledger:** `docs/handoff/world-state-merge/handoff.md`. + diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index eeeb6146..9660389a 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -4,6 +4,7 @@ """ import os +import re import traceback from flask import request, jsonify, send_file @@ -2106,6 +2107,280 @@ def get_simulation_trajectory(simulation_id: str): }), 500 +WORLD_STATE_MAX_BYTES = 8 * 1024 * 1024 +WORLD_STATE_MAX_HISTORY = 200 +WORLD_STATE_MAX_EVENTS = 40 +WORLD_STATE_MAX_PROBLEMS = 12 # arbiter problems kept per history row (the kernel records at most 12) +WORLD_STATE_MAX_DIGEST = 2000 # per-round digest text, the same bound as the latest digest + + +def _bounded_jsonl_rows(path: str): + """Dict rows of a per-run JSONL artifact, in file order, or [] when the file is missing. + + Same posture as the world-state file: a regular file only (symlinks refused), the + WORLD_STATE_MAX_BYTES size cap, malformed or non-object lines skipped. + """ + import json + if os.path.islink(path) or not os.path.isfile(path) or os.path.getsize(path) > WORLD_STATE_MAX_BYTES: + return [] + rows = [] + try: + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, dict): + rows.append(row) + except OSError: + return [] + return rows + + +def _contained_sim_dir(simulation_id: str): + """realpath containment under OASIS_SIMULATION_DATA_DIR (same posture as the trajectory route).""" + raw_id = str(simulation_id or "") + if (not raw_id or raw_id in (".", "..") or "/" in raw_id or "\\" in raw_id + or any(ord(char) < 32 or ord(char) == 127 for char in raw_id)): + return None + sim_root = os.path.realpath(Config.OASIS_SIMULATION_DATA_DIR) + sim_dir = os.path.realpath(os.path.join(sim_root, raw_id)) + if os.path.commonpath([sim_dir, sim_root]) != sim_root or sim_dir == sim_root: + return None + return sim_dir + + +_FEED_PLATFORM_RE = re.compile(r"[a-z0-9_]{1,32}") + + +def _feed_rows_for_round(simulation_id: str, platform: str, round_arg, kind: str, limit, offset): + """PERIOD VIEW: the posts (or comments) of one round of a platform's feed. + + Posts of round r are those created in r plus the parents of replies written in r, so a + thread discussed this period shows with only this period's replies; every row carries + ``round`` and the response carries the round's ``period_label``. Containment and bounds + follow the world-state route (``_contained_sim_dir``); the index comes from + ``services.feed_rounds`` (read-only, cached on the artefacts' mtime/size). + """ + import sqlite3 + from ..services.feed_rounds import cached_feed_round_index, ids_for_round, load_platform_rows + try: + rnd = int(round_arg) + except (TypeError, ValueError): + return jsonify({"success": False, "error": "round 必须是整数"}), 400 + platform = str(platform or "reddit") + if not _FEED_PLATFORM_RE.fullmatch(platform): + return jsonify({"success": False, "error": "platform 非法"}), 400 + sim_dir = _contained_sim_dir(simulation_id) + if sim_dir is None or not os.path.isdir(sim_dir): + return jsonify({"success": False, "error": f"模拟不存在: {simulation_id}"}), 404 + try: + limit = max(1, min(int(limit or 50), 2000)) + offset = max(0, int(offset or 0)) + except (TypeError, ValueError): + limit, offset = 50, 0 + index = cached_feed_round_index(sim_dir, platform) + labels = {r["round"]: r["period_label"] for r in index["rounds"]} + db_path = os.path.join(sim_dir, f"{platform}_simulation.db") + _posts, comments, _agents = load_platform_rows(db_path) + post_ids, comment_ids = ids_for_round(index, rnd, comments) + if kind == "posts": + table, id_col, ids, rounds_map = "post", "post_id", post_ids, index["post_round"] + else: + table, id_col, ids, rounds_map = "comment", "comment_id", comment_ids, index["comment_round"] + rows = [] + if ids and os.path.isfile(db_path) and not os.path.islink(db_path): + uri = "file:" + os.path.abspath(db_path).replace("?", "%3F") + "?mode=ro" + conn = sqlite3.connect(uri, uri=True, timeout=5) + conn.row_factory = sqlite3.Row + try: + cur = conn.cursor() + for start in range(0, len(ids), 500): + chunk = ids[start:start + 500] + marks = ",".join("?" for _ in chunk) + try: + cur.execute(f"SELECT * FROM {table} WHERE {id_col} IN ({marks})", chunk) + rows.extend(dict(r) for r in cur.fetchall()) + except sqlite3.OperationalError: + break + finally: + conn.close() + rows.sort(key=lambda r: (str(r.get("created_at") or ""), int(r.get(id_col) or 0)), reverse=True) + for r in rows: + r["round"] = rounds_map.get(int(r.get(id_col) or -1)) + page = rows[offset:offset + limit] + return jsonify({ + "success": True, + "data": { + "platform": platform, + "round": rnd, + "period_label": labels.get(rnd, ""), + "total": len(rows), + "count": len(page), + kind: page, + } + }) + + +@simulation_bp.route('//feed-rounds', methods=['GET']) +def get_simulation_feed_rounds(simulation_id: str): + """PERIOD VIEW: the rounds of a platform's feed with their period labels, headlines and + post/reply counts (``services.feed_rounds``). Empty ``rounds`` when the run has no + action log yet; 404 for an unknown or uncontained simulation id.""" + try: + from ..services.feed_rounds import cached_feed_round_index, public_index + platform = str(request.args.get('platform', 'reddit') or "reddit") + if not _FEED_PLATFORM_RE.fullmatch(platform): + return jsonify({"success": False, "error": "platform 非法"}), 400 + sim_dir = _contained_sim_dir(simulation_id) + if sim_dir is None or not os.path.isdir(sim_dir): + return jsonify({"success": False, "error": f"模拟不存在: {simulation_id}"}), 404 + return jsonify({"success": True, "data": public_index(cached_feed_round_index(sim_dir, platform))}) + except Exception as e: # noqa: BLE001 + logger.error(f"读取信息流轮次索引失败: {str(e)}") + return jsonify({"success": False, "error": str(e)}), 500 + + +def _world_interactions(world: dict, limit: int = 24) -> list: + """Offers and agreements of a ``world_state.json`` world block, open first then newest.""" + names = {} + for a in (world.get("actors") or []): + if isinstance(a, dict) and a.get("agent_id") is not None: + names[a.get("agent_id")] = str(a.get("name") or f"Agent {a.get('agent_id')}") + rows = [it for it in (world.get("interactions") or []) if isinstance(it, dict) and it.get("id")] + rows.sort(key=lambda it: (0 if it.get("status") in ("open", "deferred") else 1, + -int(it.get("resolved_round") or it.get("opened_round") or 0), str(it.get("id")))) + out = [] + for it in rows[:limit]: + out.append({ + "id": str(it.get("id"))[:40], "kind": str(it.get("kind") or "proposal")[:16], + "status": str(it.get("status") or "open")[:16], + "proposer": names.get(it.get("proposer"), str(it.get("proposer"))), + "recipient": names.get(it.get("recipient"), str(it.get("recipient"))), + "terms": str(it.get("terms") or "")[:300], "opened_round": it.get("opened_round"), + "expires_round": it.get("expires_round"), "resolved_round": it.get("resolved_round"), + "response": str(it.get("response") or "")[:200], "obligations": len(it.get("obligations") or []), + }) + return out + + +@simulation_bp.route('//world-state', methods=['GET']) +def get_simulation_world_state(simulation_id: str): + """REFLEXIVE WORLD: the latest committed world (facts, actors, relationships, events) + plus a bounded per-round history for the live World State panel. + + 404 while the world has not committed a round yet (early rounds, hours mode, legacy + runs); path containment + symlink refusal + size bounds as for the trajectory route. + ``?rows=N`` bounds the history (default 24, max 200). Each history row carries the + round's bounded ``arbiter_problems`` (and ``error`` when the update itself failed) plus + the agent-facing ``digest`` of that round, joined from ``world_digest.jsonl`` under the + same containment rules, so the panel can show any committed period, not only the latest. + """ + try: + sim_dir = _contained_sim_dir(simulation_id) + if sim_dir is None: + return jsonify({"success": False, "error": f"模拟不存在: {simulation_id}"}), 404 + path = os.path.join(sim_dir, "world_state.json") + if os.path.islink(path) or not os.path.isfile(path): + return jsonify({"success": False, + "error": "世界状态尚未生成(非反身世界模式或模拟尚在早期轮次)"}), 404 + if os.path.getsize(path) > WORLD_STATE_MAX_BYTES: + return jsonify({"success": False, "error": "世界状态文件超出大小上限"}), 413 + import json + try: + with open(path, "r", encoding="utf-8") as f: + payload = json.load(f) + except ValueError: + return jsonify({"success": False, "error": "世界状态文件解析失败"}), 500 + if not isinstance(payload, dict): + return jsonify({"success": False, "error": "世界状态文件解析失败"}), 500 + world = payload.get("world") if isinstance(payload.get("world"), dict) else {} + try: + rows_wanted = max(1, min(WORLD_STATE_MAX_HISTORY, int(request.args.get("rows", 24)))) + except (TypeError, ValueError): + rows_wanted = 24 + # per-round digests (what the agents were shown), keyed by round; the latest one is + # also the top-level ``digest`` below. Only the text travels — never the shares. + digests = {} + for drow in _bounded_jsonl_rows(os.path.join(sim_dir, "world_digest.jsonl")): + if drow.get("round") is not None: + digests[drow.get("round")] = str(drow.get("digest") or "")[:WORLD_STATE_MAX_DIGEST] + history = [] + for row in _bounded_jsonl_rows(os.path.join(sim_dir, "world_state_history.jsonl"))[-rows_wanted:]: + problems = row.get("arbiter_problems") if isinstance(row.get("arbiter_problems"), list) else [] + entry = { + "round": row.get("round"), "period_label": row.get("period_label"), + "headline": str(row.get("headline") or "")[:300], + "narrative": str(row.get("narrative") or "")[:700], + "arbiter_status": row.get("arbiter_status"), + "arbiter_problems": [str(p)[:200] for p in problems[:WORLD_STATE_MAX_PROBLEMS]], + "decisions": row.get("decisions"), + "diff": row.get("diff") if isinstance(row.get("diff"), dict) else {}, + "scenario_pressure": row.get("scenario_pressure") or {}, + "deterministic": row.get("deterministic") or {}, + "digest": digests.get(row.get("round"), ""), + } + if row.get("error"): + entry["error"] = str(row.get("error"))[:200] + history.append(entry) + variables = [] + for v in (world.get("variables") or [])[:64]: + if not isinstance(v, dict): + continue + hist = [h for h in (v.get("history") or []) if isinstance(h, dict)][-12:] + variables.append({"id": v.get("id"), "name": v.get("name"), "value": v.get("value"), + "unit": v.get("unit"), "kind": v.get("kind"), "provenance": v.get("provenance"), + "as_of": v.get("as_of"), "history": hist}) + actors = [] + for a in (world.get("actors") or [])[:64]: + if not isinstance(a, dict): + continue + commitments = [c for c in (a.get("commitments") or []) if isinstance(c, dict)] + actors.append({"agent_id": a.get("agent_id"), "name": a.get("name"), + "agency_class": a.get("agency_class"), "archetype": a.get("archetype"), + "capacity_per_round": a.get("capacity_per_round"), + "reputation": a.get("reputation"), "last_action": a.get("last_action"), + "pending_commitments": [c for c in commitments if c.get("status") in ("pending", "due")][:6]}) + relationships = [] + for r in (world.get("relationships") or [])[:200]: + if isinstance(r, dict): + relationships.append({k: r.get(k) for k in ("source", "target", "kind", "valence", "polarity", + "strength", "last_changed_round")}) + events = [e for e in (world.get("events") or []) if isinstance(e, dict)][-WORLD_STATE_MAX_EVENTS:] + data = { + "simulation_id": str(simulation_id), + "committed_round": payload.get("committed_round"), + "headline": str(payload.get("headline") or "")[:300], + "brief": str(payload.get("brief") or "")[:4000], + "digest": str(payload.get("digest") or "")[:2000], + "summary": payload.get("summary") if isinstance(payload.get("summary"), dict) else {}, + "question": world.get("question"), + "as_of_date": world.get("as_of_date"), + "horizon_date": world.get("horizon_date"), + "period": world.get("period"), + "variables": variables, + "actors": actors, + "relationships": relationships, + "pending_effects": [fx for fx in (world.get("pending_effects") or []) if isinstance(fx, dict)][:20], + "recent_events": events, + "history": history, + # OFFERS: open offers first, then the latest settled ones (names resolved, bounded) + "interactions": _world_interactions(world), + # MODELLING GAPS the seed reported (what the research could not ground) + "seed_gaps": [{"kind": str(g.get("kind") or "")[:40], "subject": str(g.get("subject") or "")[:120], + "note": str(g.get("note") or "")[:160]} + for g in (world.get("seed_gaps") or []) if isinstance(g, dict)][:32], + } + return jsonify({"success": True, "data": data}) + except Exception as e: + logger.error(f"读取世界状态失败: {str(e)}") + return jsonify({"success": False, "error": str(e)}), 500 + + @simulation_bp.route('//agent-stats', methods=['GET']) def get_agent_stats(simulation_id: str): """ @@ -2151,6 +2426,11 @@ def get_simulation_posts(simulation_id: str): platform = request.args.get('platform', 'reddit') limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) + # PERIOD VIEW: ``round=N`` → only the posts of that round (plus the parents of its + # replies), each row carrying its ``round``; without it the legacy path below is unchanged. + round_arg = request.args.get('round') + if round_arg not in (None, ''): + return _feed_rows_for_round(simulation_id, platform, round_arg, 'posts', limit, offset) sim_dir = os.path.join( os.path.dirname(__file__), @@ -2229,6 +2509,10 @@ def get_simulation_comments(simulation_id: str): post_id = request.args.get('post_id') limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) + # PERIOD VIEW: ``round=N`` → only the replies written in that round (legacy path unchanged). + round_arg = request.args.get('round') + if round_arg not in (None, ''): + return _feed_rows_for_round(simulation_id, platform, round_arg, 'comments', limit, offset) sim_dir = os.path.join( os.path.dirname(__file__), diff --git a/backend/app/config.py b/backend/app/config.py index c4fb830a..ef74bbb2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -630,6 +630,9 @@ def context_window_for(cls, provider): # EXECPLAN2 I-6-4 REPORT_ABORT_ON_LLM_OUTAGE = os.environ.get('REPORT_ABORT_ON_LLM_OUTAGE', 'true').strip().lower() == 'true' REPORT_SECTION_RETRY_MAX = int(os.environ.get('REPORT_SECTION_RETRY_MAX', '2') or '2') # RQ-1 1→2;0=旧的无重试 REPORT_SECTION_RETRY_BACKOFF_S = float(os.environ.get('REPORT_SECTION_RETRY_BACKOFF_S', '8.0') or '8.0') + # FILTER-SAFE:章节被提供方内容审查拒绝后的处置——采纳仅因工具下限被退回的合格草稿;无草稿时不退避、 + # 立即升级重试阶梯(L1 机构称谓写作指令+工具下限 1+检索回包截断;L2 无检索工具)。false=旧的原样退避重试。 + REPORT_FILTER_SAFE_LADDER = os.environ.get('REPORT_FILTER_SAFE_LADDER', 'true').strip().lower() == 'true' REPORT_CRITIQUE_BEFORE_PROSE = os.environ.get('REPORT_CRITIQUE_BEFORE_PROSE', 'true').strip().lower() == 'true' FORECAST_BINARY_CONTRARIAN = os.environ.get('FORECAST_BINARY_CONTRARIAN', 'true').strip().lower() == 'true' FORECAST_SIM_SENSITIVITY = os.environ.get('FORECAST_SIM_SENSITIVITY', 'true').strip().lower() == 'true' @@ -1077,6 +1080,26 @@ def _persist_env(cls, updates): SIM_DECISION_CHANNEL_INBAND = os.environ.get('SIM_DECISION_CHANNEL_INBAND', 'true').strip().lower() == 'true' # WorldState.step 熵地板:按时段天数向种子基率先验混合,防止长时域份额锁死(仅日历模式生效)。 WORLDSTATE_ENTROPY_MIX = os.environ.get('WORLDSTATE_ENTROPY_MIX', 'true').strip().lower() == 'true' + # —— Reflexive world model (docs/handoff/reflexive-world-state/design.md) —— + # Calendar mode only. PREPARE emits a sealed ``world_model`` block (state variables from + # research + official data, actor agency classes, valenced relationships); the runner then + # runs one structured decision per deciding actor per round, publishes the statement, and + # commits ONE world update per paired round (deterministic kernel + bounded arbiter LLM call) + # that every agent observes next round. false → legacy in-band scenario evolution, byte-identical. + SIM_WORLD_MODEL = os.environ.get('SIM_WORLD_MODEL', 'true').strip().lower() == 'true' + # Arbiter LLM call that resolves contested moves / moves state variables; false = mechanical + # updates only (capacity, commitments, relationship nudges, due effects, scheduled events). + SIM_WORLD_ARBITER = os.environ.get('SIM_WORLD_ARBITER', 'true').strip().lower() == 'true' + # Seconds a platform waits for the other platform's round before committing with what it has. + SIM_WORLD_BARRIER_TIMEOUT_S = float(os.environ.get('SIM_WORLD_BARRIER_TIMEOUT_S', '1800') or '1800') + # Cap on deciding actors per round (principals first, then active named actors by influence). + SIM_WORLD_DECISION_MAX_ACTORS = int(os.environ.get('SIM_WORLD_DECISION_MAX_ACTORS', '24') or '24') + # Concurrent decision LLM calls per round. + SIM_WORLD_DECISION_CONCURRENCY = int(os.environ.get('SIM_WORLD_DECISION_CONCURRENCY', '6') or '6') + # Seeded state variables cap (quantitative facts first, then official series). + SIM_WORLD_MAX_VARIABLES = int(os.environ.get('SIM_WORLD_MAX_VARIABLES', '24') or '24') + # Per-round clamp on arbiter numeric changes as a fraction of the current magnitude (I-2). + SIM_WORLD_MAX_CHANGE_RATIO = float(os.environ.get('SIM_WORLD_MAX_CHANGE_RATIO', '0.5') or '0.5') OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations') # —— OASIS 并发上限(每轮在飞 LLM 请求数)单一真源(EXECPLAN2 I-8-4)—— diff --git a/backend/app/services/agent_decision.py b/backend/app/services/agent_decision.py new file mode 100644 index 00000000..93036004 --- /dev/null +++ b/backend/app/services/agent_decision.py @@ -0,0 +1,824 @@ +"""Structured per-round strategic decisions for simulation actors (design §2.4). + +An actor's round used to be one free-text post. This module gives every +deciding actor a realistic, motivated option set and elicits ONE structured +decision per round: + +* ``AGENCY_CLASSES`` and ``infer_agency_class`` map a researched actor + (government, regulator, firm, union, NGO, individual leader…) to the levers it + realistically holds. +* ``ACTION_CATALOG`` lists the catalogued action templates per agency class + (kind, capacity cost, lag until the effect lands, reversibility). +* ``build_option_menu`` grounds the menu in the world: responses to moves aimed + at the actor, the actor's own documented plans, class options filtered by the + capacity it has left, and ``hold_position``. +* ``build_decision_prompt`` / ``parse_decision`` turn the menu into a bounded + JSON contract and validate the model's answer; any failure degrades to + ``hold_position`` with a typed status (``failed`` / ``invalid`` / + ``language_mismatch``) so a dead decision step is never mistaken for + strategic patience. A public statement whose script does not match the run + language (CJK vs Latin) is rejected for publication the same way, and every + typed non-decision carries its ``problems`` into ``world_actions.jsonl``. + +Pure except for the injected ``llm.chat_json`` call in ``elicit_decision``. +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from ..utils.llm_errors import describe_llm_error +from .world_model import (ActorState, INTERACTION_TERMS_MAX_CHARS, PROPOSAL_OPTION_IDS, WorldModel, + _text, normalize_label) + +logger = logging.getLogger(__name__) + +DECISION_CONTRACT_VERSION = "world-decision/v1" +ABSTAIN = "abstain" +HOLD_OPTION_ID = "hold_position" +MAX_OPTIONS = 10 +STATEMENT_MAX_CHARS = 480 +RATIONALE_MAX_CHARS = 320 +LANGUAGE_MISMATCH = "language_mismatch" +DECISION_STATUSES = ("ok", "invalid", "failed", LANGUAGE_MISMATCH) +MAX_PROBLEMS = 6 +DECISION_ERROR_SNIPPET_CHARS = 200 + +# Script heuristic for the run-language check: CJK ideographs / kana / hangul against Latin +# letters. One CJK character carries about three Latin letters' worth of text — the same +# weighting the runner uses to infer the run language from the forecast question — so a +# Latin statement that merely quotes a Chinese name stays Latin, and vice versa. +_CJK_CHAR_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]") +_LATIN_CHAR_RE = re.compile(r"[A-Za-z\u00c0-\u024f]") +_CJK_LANGUAGE_CODES = ("zh", "cn", "ja", "jp", "ko", "kr") +_CJK_LANGUAGE_WORDS = ("chinese", "mandarin", "cantonese", "japanese", "korean", "中文", "汉语", "汉文", "普通话", + "简体", "繁體", "繁体", "日本語", "日语", "한국어", "韩语") +_LATIN_LANGUAGE_CODES = ("en", "es", "fr", "de", "pt", "it", "nl") +_LATIN_LANGUAGE_WORDS = ("english", "spanish", "español", "espanol", "french", "français", "francais", "german", + "deutsch", "portuguese", "português", "portugues", "italian", "italiano", "dutch", + "nederlands", "英文", "英语") + +ACTION_KINDS = ("policy", "economic", "coalition", "communication", "legal", "coercive", + "operational", "wait") + +AGENCY_CLASSES: Tuple[str, ...] = ( + "sovereign_government", "regulator_agency", "legislature", "central_bank", "judiciary", + "political_party", "firm", "state_owned_enterprise", "financial_institution", + "industry_coalition", "labor_union", "ngo_advocacy", "international_org", + "research_institution", "individual_leader", "public_collective", "media_outlet", + "other_actor", +) + +DEFAULT_CAPACITY_BY_CLASS: Dict[str, int] = { + "sovereign_government": 3, "regulator_agency": 2, "legislature": 2, "central_bank": 2, + "judiciary": 1, "political_party": 2, "firm": 2, "state_owned_enterprise": 2, + "financial_institution": 2, "industry_coalition": 2, "labor_union": 2, "ngo_advocacy": 1, + "international_org": 2, "research_institution": 1, "individual_leader": 2, + "public_collective": 1, "media_outlet": 1, "other_actor": 2, +} + +# (keywords, class) — first match wins; order from specific to generic. +_AGENCY_HINTS: Tuple[Tuple[Tuple[str, ...], str], ...] = ( + (("central bank", "federal reserve", "the fed", "fed", "fomc", "ecb", "people's bank", "pboc", "boj", + "bank of england", "monetary authority", "央行", "中央银行", "人民银行", "联储"), "central_bank"), + (("supreme court", "court of", "tribunal", "judiciary", "judge", "法院", "法官", "仲裁"), "judiciary"), + (("regulator", "regulatory", "commission", "authority", "agency", "administration", "fcc", "sec ", + "ftc", "doj", "antitrust", "watchdog", "监管", "管理局", "委员会", "总局", "监察"), "regulator_agency"), + (("parliament", "congress", "senate", "senator", "congressman", "congresswoman", "lawmaker", + "house of representatives", "legislature", "legislator", "assembly", "bundestag", "diet", + "议会", "国会", "参议院", "众议院", "立法", "议员"), "legislature"), + (("government", "ministry", "minister", "president", "prime minister", "cabinet", "white house", + "administration of", "state council", "kremlin", "treasury", "department of", "政府", "国务院", + "部长", "总统", "总理", "内阁", "财政部", "外交部", "商务部"), "sovereign_government"), + (("party", "campaign", "candidate", "coalition government", "政党", "候选人", "竞选"), "political_party"), + (("imf", "world bank", "united nations", " un ", "wto", "oecd", "nato", "european union", + "european commission", "asean", "opec", "g7", "g20", "国际组织", "联合国", "世贸", "欧盟", "欧佩克"), + "international_org"), + (("union", "workers", "labor", "labour", "federation of", "guild", "工会", "工人"), "labor_union"), + (("association", "alliance", "industry group", "chamber of commerce", "consortium", "trade group", + "lobby", "协会", "商会", "联盟", "行业组织"), "industry_coalition"), + (("ngo", "non-profit", "nonprofit", "foundation", "advocacy", "watch", "activist", "rights group", + "greenpeace", "amnesty", "非政府", "基金会", "公益", "维权"), "ngo_advocacy"), + (("university", "institute", "laboratory", "lab ", "research", "think tank", "academy", "大学", + "研究所", "研究院", "实验室", "智库"), "research_institution"), + (("newspaper", "news", "media", "broadcaster", "journalist", "reporter", "press", "times", + "post", "journal", "媒体", "报", "新闻", "记者", "电视台"), "media_outlet"), + (("bank", "fund", "capital", "asset management", "investors", "insurer", "exchange", "brokerage", + "银行", "基金", "资本", "投资者", "交易所", "保险"), "financial_institution"), + (("state-owned", "state owned", "soe", "national oil", "国企", "国有", "中石", "国家电网"), + "state_owned_enterprise"), + (("corporation", "company", "inc", "ltd", "llc", "plc", "group", "holdings", "technologies", + "motors", "semiconductor", "manufacturer", "supplier", "startup", "firm", "enterprise", + "公司", "集团", "企业", "厂商", "制造商"), "firm"), + (("ceo", "founder", "chairman", "executive", "governor", "mayor", "senator", "secretary", + "leader", "director", "spokesperson", "influencer", "commentator", "person", + "首席执行官", "创始人", "董事长", "州长", "市长", "参议员", "领导人", "个人"), "individual_leader"), + (("public", "consumers", "voters", "citizens", "residents", "community", "fans", "users", + "households", "farmers", "patients", "students", "公众", "消费者", "选民", "市民", "居民", + "用户", "农民", "学生", "网民"), "public_collective"), +) + +_INDIVIDUAL_TYPES = {"person", "individual", "politician", "executive", "leader", "official", + "professor", "expert", "ceo", "founder", "candidate", "个人", "人物"} +_COLLECTIVE_TYPES = {"collective", "group", "community", "public", "audience", "群体", "公众"} + + +class ActionTemplate: + """One catalogued action an agency class can realistically take.""" + + __slots__ = ("id", "label", "kind", "cost", "lag", "reversibility", "targets_actor") + + def __init__(self, id: str, label: str, kind: str, cost: int = 1, lag: int = 0, + reversibility: str = "reversible", targets_actor: bool = False) -> None: + self.id = id + self.label = label + self.kind = kind if kind in ACTION_KINDS else "communication" + self.cost = max(0, int(cost)) + self.lag = max(0, int(lag)) + self.reversibility = reversibility if reversibility in ("reversible", "sticky", "irreversible") else "reversible" + self.targets_actor = bool(targets_actor) + + def to_option(self, **extra: Any) -> Dict[str, Any]: + d = {"id": self.id, "label": self.label, "kind": self.kind, "cost": self.cost, + "lag": self.lag, "reversibility": self.reversibility, "targets_actor": self.targets_actor} + d.update(extra) + return d + + +def _t(id: str, label: str, kind: str, cost: int = 1, lag: int = 0, rev: str = "reversible", + targets: bool = False) -> ActionTemplate: + return ActionTemplate(id, label, kind, cost, lag, rev, targets) + + +_COMMON: Tuple[ActionTemplate, ...] = ( + _t("public_statement", "Issue a public statement or warning", "communication", 1, 0), + _t("open_talks", "Open or join negotiations with another actor", "coalition", 1, 1, "reversible", True), + _t("form_alliance", "Form or deepen an alliance / joint initiative", "coalition", 2, 1, "sticky", True), + _t("break_alliance", "Withdraw from an alliance or agreement", "coalition", 2, 0, "sticky", True), + _t("signal_concession", "Offer a concession or compromise", "communication", 1, 0, "reversible", True), + _t("escalate_pressure", "Escalate pressure on another actor (campaign, threat, retaliation)", + "coercive", 2, 1, "sticky", True), +) + +ACTION_CATALOG: Dict[str, Tuple[ActionTemplate, ...]] = { + "sovereign_government": _COMMON + ( + _t("impose_measure", "Impose or raise a tariff, sanction, export control or restriction", + "policy", 3, 1, "sticky", True), + _t("lift_measure", "Lift, delay or soften a restriction", "policy", 2, 1, "sticky", True), + _t("subsidize", "Announce subsidies, procurement or an investment programme", "economic", 3, 2, "sticky"), + _t("legislate", "Introduce legislation, decree or regulation", "policy", 3, 2, "sticky"), + _t("sign_agreement", "Sign a bilateral or multilateral agreement", "coalition", 3, 1, "irreversible", True), + _t("security_action", "Deploy security, enforcement or military-adjacent measures", "coercive", 3, 1, "sticky", True), + ), + "regulator_agency": _COMMON + ( + _t("open_inquiry", "Open an investigation, review or inquiry", "legal", 2, 1, "sticky", True), + _t("issue_ruling", "Issue a ruling, approval, fine or licence decision", "legal", 2, 1, "irreversible", True), + _t("propose_rule", "Propose or finalise a rule / standard", "policy", 2, 2, "sticky"), + _t("grant_waiver", "Grant an exemption, waiver or extension", "policy", 1, 0, "reversible", True), + ), + "legislature": _COMMON + ( + _t("introduce_bill", "Introduce or advance a bill", "policy", 2, 2, "sticky"), + _t("hold_hearing", "Hold a hearing or demand testimony", "legal", 1, 0, "reversible", True), + _t("pass_vote", "Hold a decisive vote (budget, confirmation, resolution)", "policy", 3, 1, "irreversible"), + _t("block_measure", "Block, delay or condition a measure", "policy", 2, 0, "reversible", True), + ), + "central_bank": _COMMON + ( + _t("rate_move", "Change the policy rate or guidance", "policy", 2, 1, "sticky"), + _t("liquidity_tool", "Deploy or withdraw a liquidity / balance-sheet tool", "economic", 2, 1, "sticky"), + _t("macroprudential", "Tighten or ease macroprudential rules", "policy", 2, 2, "sticky"), + ), + "judiciary": ( + _t("public_statement", "Publish an opinion or procedural order", "communication", 1, 0), + _t("issue_ruling", "Issue a ruling or injunction", "legal", 1, 0, "irreversible", True), + _t("schedule_review", "Schedule or expedite a review", "legal", 1, 1, "reversible", True), + ), + "political_party": _COMMON + ( + _t("campaign_push", "Launch a campaign push or mobilisation", "communication", 2, 0), + _t("platform_shift", "Shift a platform position or policy pledge", "policy", 2, 1, "sticky"), + _t("endorse_oppose", "Endorse or oppose a measure or candidate", "communication", 1, 0, "sticky", True), + ), + "firm": _COMMON + ( + _t("invest_capex", "Commit capital: new plant, capacity, R&D programme", "economic", 2, 2, "sticky"), + _t("cut_or_delay", "Cut, delay or divest a project", "economic", 2, 1, "sticky"), + _t("price_move", "Change pricing, terms or supply allocation", "economic", 1, 0, "reversible", True), + _t("product_launch", "Launch a product, service or technology milestone", "operational", 2, 1, "irreversible"), + _t("partnership_deal", "Sign a partnership, supply or licensing deal", "economic", 2, 1, "sticky", True), + _t("lobby", "Lobby or petition policymakers", "communication", 1, 1, "reversible", True), + _t("litigate", "File or threaten litigation", "legal", 2, 1, "sticky", True), + _t("relocate_supply", "Shift sourcing, production or markets", "operational", 3, 2, "sticky"), + ), + "state_owned_enterprise": _COMMON + ( + _t("invest_capex", "Commit capital under state direction", "economic", 3, 2, "sticky"), + _t("price_move", "Change pricing, output or allocation", "economic", 1, 0, "reversible", True), + _t("partnership_deal", "Sign a supply or partnership deal", "economic", 2, 1, "sticky", True), + _t("relocate_supply", "Shift sourcing, production or markets", "operational", 3, 2, "sticky"), + ), + "financial_institution": _COMMON + ( + _t("reprice_risk", "Reprice risk: lending terms, ratings, exposure limits", "economic", 1, 0, "reversible", True), + _t("fund_or_withdraw", "Provide or withdraw financing / investment", "economic", 2, 1, "sticky", True), + _t("market_call", "Publish a market view or forecast", "communication", 1, 0), + ), + "industry_coalition": _COMMON + ( + _t("industry_position", "Publish an industry position or standard", "communication", 1, 0), + _t("lobby", "Lobby or petition policymakers", "communication", 1, 1, "reversible", True), + _t("coordinate_members", "Coordinate member action (pledge, boycott, standard adoption)", "coalition", 2, 1, "sticky"), + ), + "labor_union": _COMMON + ( + _t("strike_action", "Call or threaten a strike / work stoppage", "coercive", 2, 0, "sticky", True), + _t("bargain", "Enter or escalate collective bargaining", "coalition", 2, 1, "reversible", True), + _t("mobilize", "Mobilise members or allies", "communication", 1, 0), + ), + "ngo_advocacy": _COMMON + ( + _t("publish_report", "Publish a report, exposé or scorecard", "communication", 1, 0), + _t("campaign", "Launch a public campaign or petition", "communication", 1, 0, "reversible", True), + _t("legal_challenge", "File a legal or regulatory complaint", "legal", 2, 1, "sticky", True), + ), + "international_org": _COMMON + ( + _t("issue_assessment", "Issue an assessment, forecast or recommendation", "communication", 1, 0), + _t("convene", "Convene members or mediate", "coalition", 2, 1, "reversible", True), + _t("program_decision", "Approve, condition or suspend a programme / financing", "economic", 3, 1, "sticky", True), + ), + "research_institution": ( + _t("publish_findings", "Publish findings or a forecast", "communication", 1, 0), + _t("advise", "Advise or brief a decision-maker", "communication", 1, 0, "reversible", True), + _t("launch_program", "Launch a research programme or pilot", "operational", 2, 2, "sticky"), + ), + "individual_leader": _COMMON + ( + _t("set_agenda", "Set the agenda: announce a priority or plan", "communication", 1, 0), + _t("broker_deal", "Broker a deal or meeting between actors", "coalition", 2, 1, "reversible", True), + _t("commit_resources", "Commit resources or make a binding pledge", "economic", 2, 1, "sticky"), + ), + "public_collective": ( + _t("public_statement", "Voice collective sentiment", "communication", 1, 0), + _t("mobilize", "Mobilise: protest, boycott or turnout", "coercive", 1, 0, "reversible", True), + _t("shift_behavior", "Shift behaviour: demand, adoption, savings", "economic", 1, 1, "sticky"), + ), + "media_outlet": ( + _t("publish_report", "Publish an investigation or editorial", "communication", 1, 0), + ), + "other_actor": _COMMON, +} + +HOLD_TEMPLATE = _t(HOLD_OPTION_ID, "Hold position: wait, observe, keep current course", "wait", 0, 0) + + +# --------------------------------------------------------------------------- +# agency class inference +# --------------------------------------------------------------------------- + +_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") + + +def _hay(*texts: Any) -> str: + """Searchable haystack: camelCase/underscore type names are split into words + (``MediaOutlet`` → ``media outlet``) so word-boundary hints can see them.""" + parts = [] + for t in texts: + if not t: + continue + raw = str(t).replace("_", " ").replace("-", " ") + parts.append(_CAMEL_RE.sub(" ", raw)) + joined = unicodedata.normalize("NFKC", " ".join(parts)).casefold() + return f" {' '.join(joined.split())} " + + +_HINT_CACHE: Dict[str, "re.Pattern[str]"] = {} + + +def _hint_matches(hay: str, hint: str) -> bool: + """ASCII hints match on word boundaries ("nato" must not hit "senator"); + CJK hints (no word boundaries in Chinese) match as substrings.""" + if any(ord(ch) > 127 for ch in hint): + return hint in hay + pattern = _HINT_CACHE.get(hint) + if pattern is None: + pattern = re.compile(r"(? str: + """Deterministic agency class from an entity type, role text and archetype. + + Explicit values that already name a class win. The archetype ``collective`` + without a stronger hint maps to ``public_collective``; media stays + ``media_outlet`` (not an agent); anything else falls back to ``firm`` for + organisation-like types, ``individual_leader`` for person types and + ``other_actor`` otherwise. Never raises. + """ + try: + for candidate in (type_name, *texts): + c = str(candidate or "").strip().casefold() + if c in AGENCY_CLASSES: + return c + hay = _hay(type_name, *texts) + for hints, klass in _AGENCY_HINTS: + if any(_hint_matches(hay, h) for h in hints): + return klass + tname = str(type_name or "").strip().casefold() + if tname in _INDIVIDUAL_TYPES: + return "individual_leader" + if str(archetype or "").casefold() == "collective" or tname in _COLLECTIVE_TYPES: + return "public_collective" + if tname in ("organization", "organisation", "company", "org", "机构", "组织"): + return "firm" + return "other_actor" + except Exception: # noqa: BLE001 + return "other_actor" + + +def default_capacity(agency_class: str) -> int: + return DEFAULT_CAPACITY_BY_CLASS.get(str(agency_class or ""), DEFAULT_CAPACITY_BY_CLASS["other_actor"]) + + +def catalog_for(agency_class: str) -> Tuple[ActionTemplate, ...]: + return ACTION_CATALOG.get(str(agency_class or ""), ACTION_CATALOG["other_actor"]) + + +# --------------------------------------------------------------------------- +# option menu +# --------------------------------------------------------------------------- + +_PLAN_KIND_HINTS = ( + (("invest", "capex", "plant", "factory", "capacity", "fund", "acquire", "acquisition", "spend", + "投资", "扩产", "建厂", "收购", "融资"), "economic", 3, 2), + (("tariff", "sanction", "ban", "restrict", "control", "regulat", "rule", "law", "bill", "policy", + "关税", "制裁", "禁令", "限制", "法规", "政策", "立法"), "policy", 3, 1), + (("alliance", "partner", "agreement", "deal", "coalition", "cooperat", "joint", "treaty", + "联盟", "合作", "协议", "伙伴", "合资"), "coalition", 2, 1), + (("sue", "lawsuit", "litig", "court", "appeal", "complaint", "permit", "licen", "filing", "file a", + "file an", "petition", "ruling", "inquiry", "诉讼", "起诉", "上诉", "许可", "申诉", "备案"), "legal", 2, 1), + (("strike", "protest", "boycott", "pressure", "retaliat", "罢工", "抗议", "抵制", "报复"), "coercive", 2, 0), + (("launch", "ship", "deploy", "roll out", "produce", "build", "expand", "发布", "部署", "投产", "量产"), + "operational", 2, 1), +) + + +def _plan_kind(text: str) -> Tuple[str, int, int]: + low = unicodedata.normalize("NFKC", text).casefold() + for hints, kind, cost, lag in _PLAN_KIND_HINTS: + if any(h in low for h in hints): + return kind, cost, lag + return "communication", 1, 0 + + +def build_option_menu(actor: ActorState, world: WorldModel, round_num: int, + *, max_options: int = MAX_OPTIONS) -> List[Dict[str, Any]]: + """Deterministic, world-grounded option menu for one actor in one round. + + Order of priority: responses to moves aimed at the actor last period, + documented plans, agency-class options the actor can still afford, and + always ``hold_position`` last. Irreversible moves already taken are not + offered again; options above the remaining capacity are dropped (the actor + may still take cheap moves). + """ + options: List[Dict[str, Any]] = [] + seen: set = set() + remaining = actor.capacity_remaining + last_round = max(0, int(round_num) - 1) + + def push(opt: Dict[str, Any]) -> None: + if opt["id"] in seen or len(options) >= max_options: + return + seen.add(opt["id"]) + options.append(opt) + + # 0) OFFERS: answers owed on open offers — the only way an agreement can come about + for it in world.open_interactions_for(actor.agent_id)[:3]: + who = world.actor_name(it.proposer) + until = world._period_label_for_round(it.expires_round) + for verb, label, kind, cost, lag in ( + ("accept", f"Accept {who}'s offer [{it.id}]: {_text(it.terms, 110)} — binds both sides", "coalition", 1, 1), + ("counter", f"Counter {who}'s offer [{it.id}] with your own terms (state them in `terms`)", "coalition", 1, 0), + ("reject", f"Reject {who}'s offer [{it.id}]", "communication", 1, 0), + ("defer", f"Defer your answer to {who}'s offer [{it.id}] (open until {until})", "communication", 0, 0), + ): + if cost <= remaining: + push({"id": f"{verb}:{it.id}", "label": label, "kind": kind, "cost": cost, "lag": lag, + "reversibility": "sticky" if verb == "accept" else "reversible", "targets_actor": False, + "suggested_targets": [who], "interaction_id": it.id, + "why": f"an offer from {who} awaits your answer (lapses after {until})"}) + # 1) responses to what was aimed at this actor last period (offers are answered above) + aimed = [e for e in world.events_in_round(last_round) + if actor.name in e.targets and e.kind not in ("offer", "agreement", "breach") + and e.option_id not in PROPOSAL_OPTION_IDS] if last_round > 0 else [] + for idx, ev in enumerate(aimed[:3]): + who = ev.actors[0] if ev.actors else "another actor" + who_id = world.resolve_actor(who) + for tmpl_id, label, kind, cost in ( + (f"respond_{idx + 1}_counter", f"Counter {who}'s move ({_text(ev.description, 80)})", "coercive", 2), + (f"respond_{idx + 1}_negotiate", f"Seek talks with {who} over its move", "coalition", 1), + (f"respond_{idx + 1}_accommodate", f"Accommodate or publicly answer {who}'s move", "communication", 1), + ): + if cost <= remaining: + push({"id": tmpl_id, "label": label, "kind": kind, "cost": cost, "lag": 0, + "reversibility": "reversible", "targets_actor": True, + "suggested_targets": [who] if who_id is not None else [], + "why": "responds to a move aimed at you last period"}) + # 2) documented plans (research-grounded) + for idx, plan in enumerate(actor.documented_plans[:4]): + kind, cost, lag = _plan_kind(plan) + if cost > remaining: + cost = min(cost, max(1, remaining)) if remaining else cost + push({"id": f"plan_{idx + 1}", "label": f"Advance your documented plan: {_text(plan, 120)}", + "kind": kind, "cost": cost, "lag": lag, "reversibility": "sticky", "targets_actor": False, + "why": "a plan or likely action documented in your dossier"}) + # 3) class catalogue filtered by capacity and prior irreversible moves + taken_irreversible = set(getattr(actor, "irreversible_option_ids", []) or []) + for tmpl in catalog_for(actor.agency_class): + if tmpl.id in taken_irreversible: + continue + if tmpl.cost > max(remaining, 0): + continue + push(tmpl.to_option()) + if len(options) >= max_options: + options = options[: max_options - 1] + options.append(HOLD_TEMPLATE.to_option(why="strategic patience is a legitimate choice")) + return options + + +def deciding_roster(agent_configs: Sequence[Dict[str, Any]], active_ids: Iterable[int], *, + max_actors: int = 24, audience_type: str = "Audience") -> List[int]: + """Who decides this round: principals always, then active named actors by influence.""" + principals: List[Tuple[float, int]] = [] + sampled: List[Tuple[float, int]] = [] + active = set(int(a) for a in active_ids) + for cfg in agent_configs or []: + if not isinstance(cfg, dict) or cfg.get("agent_id") is None: + continue + try: + aid = int(cfg["agent_id"]) + infl = float(cfg.get("influence_weight", 1.0) or 0.0) + except (TypeError, ValueError): + continue + if str(cfg.get("entity_type") or "") == audience_type: + continue + if str(cfg.get("cadence") or "sampled") == "principal": + principals.append((-infl, aid)) + elif aid in active: + sampled.append((-infl, aid)) + ordered = [aid for _, aid in sorted(principals)] + [aid for _, aid in sorted(sampled)] + out: List[int] = [] + for aid in ordered: + if aid not in out: + out.append(aid) + if len(out) >= max(1, int(max_actors)): + break + return out + + +# --------------------------------------------------------------------------- +# prompt and parsing +# --------------------------------------------------------------------------- + +def _render_options(options: List[Dict[str, Any]]) -> str: + lines = [] + for o in options: + bits = [f"[{o['id']}] {o['label']}", f"kind={o['kind']}", f"cost={o['cost']}"] + if o.get("lag"): + bits.append(f"effect lands in {o['lag']} period(s)") + if o.get("reversibility") and o["reversibility"] != "reversible": + bits.append(o["reversibility"]) + if o.get("targets_actor"): + bits.append("needs a target actor") + if o.get("why"): + bits.append(o["why"]) + lines.append("- " + " · ".join(bits)) + return "\n".join(lines) + + +def build_decision_prompt(actor: ActorState, *, world_clock: str, public_brief: str, + private_brief: str, options: List[Dict[str, Any]], + scenarios: Sequence[str], cast_names: Sequence[str], + language: str = "", recent_discourse: str = "") -> str: + """User-turn prompt for the structured decision (the actor's role brief is the system turn).""" + sc = "; ".join(str(s) for s in scenarios) or "(none)" + cast = ", ".join(str(n) for n in cast_names[:30]) or "(the other actors in this simulation)" + goals = "; ".join(actor.goals) if actor.goals else "" + inc = [] + if actor.incentives.get("gains_if"): + inc.append(f"you gain if: {actor.incentives['gains_if']}") + if actor.incentives.get("loses_if"): + inc.append(f"you lose if: {actor.incentives['loses_if']}") + lang_rule = f"Write public_statement in {language}." if language else "" + parts = [ + world_clock.strip(), + public_brief.strip(), + private_brief.strip(), + ] + if recent_discourse.strip(): + parts.append(recent_discourse.strip()) + parts.append( + f"# DECIDE YOUR MOVE FOR THIS PERIOD — {actor.name}\n" + "Act strictly as this real-world actor. Choose the ONE most consequential move you actually " + "take over the whole period, grounded in your documented goals, incentives, capabilities, " + "constraints, alliances and red lines. Realistic options for you:\n" + f"{_render_options(options)}\n" + + (f"Your documented goals: {goals}\n" if goals else "") + + (("Your incentives: " + "; ".join(inc) + "\n") if inc else "") + + "Rules: pick an option id from the list (or \"custom\" with a kind from " + f"{', '.join(ACTION_KINDS)} when none fits); name real targets from the cast when the option " + "needs one; magnitude 0-1 is how far you go; public_statement is what you publicly say or " + "announce this period (1-3 sentences, concrete: decision, numbers, dates, conditions); " + "private_rationale is for your own record; terms states the exact terms when your move makes " + "an offer to another actor or counters one (what you give, what you expect, by when) — an " + "offer binds nobody until the other side accepts it; commitment is what you bind yourself to and when " + "(due_periods ≥ 1) or null; alliance_moves lists any alliance you propose/strengthen/break; " + "scenario_commitment names which candidate scenario your move pushes toward, or \"abstain\" " + f"if your move has no real bearing on the outcome. Candidate scenarios: {sc}.\n" + f"Cast: {cast}.\n" + f"{lang_rule}\n" + "Return ONLY JSON:\n" + '{"option_id": "", "custom_action": {"kind": "...", "label": "..."} | null, ' + '"targets": ["", ...], "magnitude": 0.0-1.0, ' + '"public_statement": "...", "private_rationale": "...", "terms": " | null", ' + '"commitment": {"description": "...", "due_periods": 1} | null, ' + '"alliance_moves": [{"target": "", "move": "propose|strengthen|break|oppose"}], ' + '"expected_reactions": [{"actor": "", "reaction": "..."}], ' + '"scenario_commitment": {"scenario": "", "confidence": 0.0-1.0}}' + ) + return "\n\n".join(p for p in parts if p) + + +def expected_script(language: Any) -> str: + """``"cjk"`` / ``"latin"`` for a run language, ``""`` when it is unset or unknown.""" + low = str(language or "").strip().casefold() + if not low or low == "auto": + return "" + code = re.split(r"[-_ (/]", low, maxsplit=1)[0] # "zh-CN" / "en_US" / "English (US)" → code + if code in _CJK_LANGUAGE_CODES or any(w in low for w in _CJK_LANGUAGE_WORDS): + return "cjk" + if code in _LATIN_LANGUAGE_CODES or any(w in low for w in _LATIN_LANGUAGE_WORDS): + return "latin" + return "" + + +def text_script(text: Any) -> str: + """Dominant script of a statement: ``"cjk"``, ``"latin"`` or ``""`` (no letters at all).""" + s = str(text or "") + cjk = len(_CJK_CHAR_RE.findall(s)) + latin = len(_LATIN_CHAR_RE.findall(s)) + if not cjk and not latin: + return "" + return "cjk" if cjk * 3 >= latin else "latin" + + +def statement_matches_language(text: Any, language: Any) -> bool: + """False only when the run language and the statement both have a known script and they differ.""" + want, have = expected_script(language), text_script(text) + return not want or not have or want == have + + +class AgentDecision: + """A validated structured decision (or a typed non-decision).""" + + def __init__(self, agent_id: int, actor_name: str, option: Dict[str, Any], *, targets: List[str], + magnitude: float, public_statement: str, private_rationale: str = "", + commitment: Optional[Dict[str, Any]] = None, + alliance_moves: Optional[List[Dict[str, str]]] = None, + expected_reactions: Optional[List[Dict[str, str]]] = None, + scenario: Optional[str] = None, scenario_confidence: float = 0.0, + status: str = "ok", round_num: int = 0, period_end: Optional[str] = None, + raw_option_id: str = "", problems: Optional[List[str]] = None, terms: str = "") -> None: + self.agent_id = int(agent_id) + self.terms = _text(terms, INTERACTION_TERMS_MAX_CHARS) # OFFERS: exact offer / counter-offer terms + self.actor_name = actor_name + self.option = dict(option) + self.targets = list(targets) + self.magnitude = max(0.0, min(1.0, float(magnitude))) + self.public_statement = public_statement + self.private_rationale = private_rationale + self.commitment = commitment + self.alliance_moves = list(alliance_moves or []) + self.expected_reactions = list(expected_reactions or []) + self.scenario = scenario + self.scenario_confidence = max(0.0, min(1.0, float(scenario_confidence))) + self.status = status + self.round = int(round_num) + self.period_end = period_end + self.raw_option_id = raw_option_id + # why a typed non-decision is not a move (validation / provider detail), for the record + self.problems = [_text(p, 300) for p in (problems or []) if _text(p, 300)][:MAX_PROBLEMS] + + @property + def ok(self) -> bool: + return self.status == "ok" + + def to_dict(self) -> Dict[str, Any]: + return { + "schema_version": DECISION_CONTRACT_VERSION, + "round": self.round, "period_end": self.period_end, + "agent_id": self.agent_id, "actor_name": self.actor_name, + "option": dict(self.option), "targets": list(self.targets), + "magnitude": round(self.magnitude, 3), + "public_statement": self.public_statement, "private_rationale": self.private_rationale, + "terms": self.terms, + "commitment": self.commitment, "alliance_moves": list(self.alliance_moves), + "expected_reactions": list(self.expected_reactions), + "scenario": self.scenario, "scenario_confidence": round(self.scenario_confidence, 3), + "status": self.status, "raw_option_id": self.raw_option_id, + "problems": list(self.problems), + } + + def scenario_commitment(self, outcome_power: Optional[float] = None) -> Optional[Dict[str, Any]]: + """Row for ``worldstate.commitments_from_decisions`` (None when abstaining).""" + if not self.ok or not self.scenario or self.scenario == ABSTAIN: + return None + row: Dict[str, Any] = {"agent_id": self.agent_id, "scenario": self.scenario, + "magnitude": round(max(0.1, self.magnitude), 3), + "confidence": round(self.scenario_confidence, 3), "round": self.round} + if outcome_power is not None: + row["outcome_power"] = outcome_power + if self.period_end: + row["period_end"] = self.period_end + return row + + +def hold_decision(actor: ActorState, *, status: str, round_num: int, period_end: Optional[str], + raw_option_id: str = "", problems: Optional[List[str]] = None) -> AgentDecision: + return AgentDecision(actor.agent_id, actor.name, HOLD_TEMPLATE.to_option(), targets=[], magnitude=0.0, + public_statement="", status=status, round_num=round_num, period_end=period_end, + raw_option_id=raw_option_id, problems=problems) + + +def _match_scenario(raw: Any, scenarios: Sequence[str]) -> Optional[str]: + text = normalize_label(raw) + if not text: + return None + if text in (ABSTAIN, "弃权", "none", "no bearing"): + return ABSTAIN + for s in scenarios: + if normalize_label(s) == text: + return str(s) + for s in scenarios: + ns = normalize_label(s) + if len(ns) >= 4 and (ns in text or text in ns): + return str(s) + return None + + +def parse_decision(raw: Any, *, actor: ActorState, world: WorldModel, options: List[Dict[str, Any]], + scenarios: Sequence[str], round_num: int, period_end: Optional[str] = None, + language: str = "") -> AgentDecision: + """Validate the model's JSON against the menu, the cast and the scenario set. + + Unknown option ids, missing payloads or malformed structures degrade to a + ``hold_position`` decision with ``status="invalid"``. Targets are resolved + to cast names; unknown names are dropped. A ``custom`` option must carry a + catalogued kind and a label. When ``language`` (the run language) is set, a + move whose ``public_statement`` is written in the wrong script (CJK vs Latin) + is rejected for publication the same way, with ``status="language_mismatch"`` + and the rejected text in ``problems``; the actor then posts organically. + """ + if not isinstance(raw, dict): + return hold_decision(actor, status="invalid", round_num=round_num, period_end=period_end) + by_id = {o["id"]: o for o in options} + raw_option = _text(raw.get("option_id") or raw.get("option"), 64) + option: Optional[Dict[str, Any]] = None + if raw_option in by_id: + option = dict(by_id[raw_option]) + elif raw_option.casefold() == "custom": + custom = raw.get("custom_action") if isinstance(raw.get("custom_action"), dict) else {} + kind = _text(custom.get("kind"), 24).casefold() + label = _text(custom.get("label"), 140) + if kind in ACTION_KINDS and label: + option = {"id": "custom", "label": label, "kind": kind, "cost": 2 if kind != "wait" else 0, + "lag": 1 if kind in ("economic", "policy", "legal") else 0, + "reversibility": "sticky" if kind in ("economic", "policy", "legal", "coercive") else "reversible", + "targets_actor": kind in ("coalition", "coercive", "legal")} + else: + # tolerate the model echoing the label instead of the id + wanted = normalize_label(raw_option) + for o in options: + if wanted and normalize_label(o["label"]).startswith(wanted[:40]): + option = dict(o) + break + if option is None: + return hold_decision(actor, status="invalid", round_num=round_num, period_end=period_end, + raw_option_id=raw_option) + targets: List[str] = [] + for t in (raw.get("targets") or [])[:6] if isinstance(raw.get("targets"), list) else []: + aid = world.resolve_actor(t) + if aid is not None and aid != actor.agent_id: + name = world.actor_name(aid) + if name not in targets: + targets.append(name) + if not targets and option.get("suggested_targets"): + targets = list(option["suggested_targets"])[:2] + try: + magnitude = float(raw.get("magnitude", 0.5)) + except (TypeError, ValueError): + magnitude = 0.5 + statement = _text(raw.get("public_statement"), STATEMENT_MAX_CHARS) + if option["id"] != HOLD_OPTION_ID and statement and not statement_matches_language(statement, language): + # the statement is what gets published: a wrong-script one is treated like an invalid + # decision (typed hold → organic post) and stays visible in world_actions.jsonl + return hold_decision(actor, status=LANGUAGE_MISMATCH, round_num=round_num, period_end=period_end, + raw_option_id=raw_option, + problems=[f"public_statement is {text_script(statement)}-script but the run " + f"language is {_text(language, 40)}: \"{_text(statement, 100)}\""]) + rationale = _text(raw.get("private_rationale"), RATIONALE_MAX_CHARS) + terms = _text(raw.get("terms"), INTERACTION_TERMS_MAX_CHARS) + commitment = None + rc = raw.get("commitment") + if isinstance(rc, dict) and _text(rc.get("description"), 220): + try: + due = max(1, int(rc.get("due_periods", 1) or 1)) + except (TypeError, ValueError): + due = 1 + commitment = {"description": _text(rc["description"], 220), "due_periods": min(due, 12)} + moves: List[Dict[str, str]] = [] + for m in (raw.get("alliance_moves") or [])[:4] if isinstance(raw.get("alliance_moves"), list) else []: + if not isinstance(m, dict): + continue + aid = world.resolve_actor(m.get("target")) + verb = _text(m.get("move"), 16).casefold() + if aid is not None and aid != actor.agent_id and verb in ("propose", "strengthen", "break", "oppose", + "join", "support", "distance", "sanction"): + moves.append({"target": world.actor_name(aid), "move": verb}) + reactions: List[Dict[str, str]] = [] + for r in (raw.get("expected_reactions") or [])[:4] if isinstance(raw.get("expected_reactions"), list) else []: + if isinstance(r, dict) and _text(r.get("actor"), 80) and _text(r.get("reaction"), 160): + reactions.append({"actor": _text(r["actor"], 80), "reaction": _text(r["reaction"], 160)}) + scenario = None + confidence = 0.0 + sc_raw = raw.get("scenario_commitment") + if isinstance(sc_raw, dict): + scenario = _match_scenario(sc_raw.get("scenario"), scenarios) + try: + confidence = float(sc_raw.get("confidence", 0.6)) + except (TypeError, ValueError): + confidence = 0.6 + elif isinstance(sc_raw, str): + scenario = _match_scenario(sc_raw, scenarios) + confidence = 0.6 + if option["id"] != HOLD_OPTION_ID and not statement: + # a move without any public trace is still a move, but we need something to publish + statement = f"{actor.name}: {option['label']}" + (f" ({', '.join(targets)})" if targets else "") + return AgentDecision(actor.agent_id, actor.name, option, targets=targets, magnitude=magnitude, + public_statement=statement, private_rationale=rationale, commitment=commitment, + alliance_moves=moves, expected_reactions=reactions, scenario=scenario, + scenario_confidence=confidence, status="ok", round_num=round_num, + period_end=period_end, raw_option_id=raw_option, terms=terms) + + +def elicit_decision(llm: Any, *, system_message: str, user_prompt: str, actor: ActorState, + world: WorldModel, options: List[Dict[str, Any]], scenarios: Sequence[str], + round_num: int, period_end: Optional[str] = None, + temperature: float = 0.4, max_tokens: int = 1200, language: str = "") -> AgentDecision: + """One structured LLM call → validated decision; failures become typed holds. + + ``llm.chat_json`` is the backend ``LLMClient`` surface: a content-filter or other + non-retryable 4xx rejection is retried once on the configured fallback provider by the + client itself before it raises; what survives is recorded in ``problems`` with the + provider's error class, HTTP status / code, a bounded snippet and the failover outcome. + """ + if llm is None: + return hold_decision(actor, status="failed", round_num=round_num, period_end=period_end, + problems=["no llm client"]) + messages: List[Dict[str, str]] = [] + if system_message: + messages.append({"role": "system", "content": str(system_message)}) + messages.append({"role": "user", "content": user_prompt}) + try: + raw = llm.chat_json(messages=messages, temperature=temperature, max_tokens=max_tokens) + except Exception as exc: # noqa: BLE001 — the round loop must survive a failed decision + detail = describe_llm_error(exc, max_chars=DECISION_ERROR_SNIPPET_CHARS) + logger.warning("decision step failed for %s (status=failed): %s", actor.name, detail) + return hold_decision(actor, status="failed", round_num=round_num, period_end=period_end, + problems=[f"llm error: {detail}"]) + return parse_decision(raw, actor=actor, world=world, options=options, scenarios=scenarios, + round_num=round_num, period_end=period_end, language=language) + + +def decision_memory_note(decision: AgentDecision, period_label: str = "") -> str: + """Private USER memory note so later rounds and the reaction step stay consistent.""" + if not decision.ok: + return "" + head = f"# YOUR MOVE THIS PERIOD{(' — ' + period_label) if period_label else ''}" + lines = [head, f"You chose: {decision.option.get('label') or decision.option.get('id')}"] + if decision.targets: + lines.append("Directed at: " + ", ".join(decision.targets)) + if decision.public_statement: + lines.append(f'You announced: "{decision.public_statement}"') + if decision.commitment: + lines.append(f"You committed to: {decision.commitment.get('description')} " + f"(within {decision.commitment.get('due_periods')} period(s))") + if decision.private_rationale: + lines.append(f"Your private reasoning: {decision.private_rationale}") + lines.append("In the response step, defend or build on this move; do not contradict it.") + return "\n".join(lines) + + +def summarize_decisions(decisions: Iterable[AgentDecision]) -> Dict[str, int]: + counts = dict.fromkeys(DECISION_STATUSES, 0) + counts["hold"] = 0 + for d in decisions: + if d.status in counts: + counts[d.status] += 1 + if d.option.get("id") == HOLD_OPTION_ID: + counts["hold"] += 1 + return counts diff --git a/backend/app/services/feed_rounds.py b/backend/app/services/feed_rounds.py new file mode 100644 index 00000000..2302f0d3 --- /dev/null +++ b/backend/app/services/feed_rounds.py @@ -0,0 +1,276 @@ +"""Per-round index of a platform's social feed (period view of the simulation tab). + +The dashboard's World State panel can select any committed period, but the feed beside it +showed the newest posts whatever the period. OASIS stores posts and comments with the +sandbox clock only, while the runner's ``/actions.jsonl`` records every +``CREATE_POST`` / ``CREATE_COMMENT`` with the round it happened in, and every +``world_update`` row with the period label of that round. This module joins the two so +the API can answer "which posts and replies belong to round r" and label the rounds. + +Posts are matched to action rows by ``(agent_id, normalised content)``, consuming the +log's occurrences in order so that an agent repeating the same text in two rounds maps +each post to its own round; a post whose content appears once in the log matches by +content alone; anything still unmatched is assigned to the round of the previous matched +post in ``post_id`` order (OASIS ids are monotonic), or the first round. Comments carry +their ``comment_id`` in the action row. +Everything is read-only and bounded (regular files only, a byte cap on the action log, +read-only SQLite); a missing artefact yields an empty index, never an error. +""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import threading +from collections import OrderedDict, deque +from typing import Any, Dict, List, Optional, Tuple + +FEED_ACTIONS_MAX_BYTES = 64 * 1024 * 1024 +FEED_CONTENT_KEY_CHARS = 2000 +_CACHE_MAX = 16 +_WS_RE = re.compile(r"\s+") + +_cache: "OrderedDict[Tuple[str, str, Tuple[int, int, int, int]], Dict[str, Any]]" = OrderedDict() +_cache_lock = threading.Lock() + + +def _norm(text: Any) -> str: + return _WS_RE.sub(" ", str(text if text is not None else "")).strip()[:FEED_CONTENT_KEY_CHARS] + + +def _as_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _regular_file(path: str) -> bool: + return bool(path) and not os.path.islink(path) and os.path.isfile(path) + + +def read_action_rounds(actions_path: str, *, max_bytes: int = FEED_ACTIONS_MAX_BYTES) -> Dict[str, Any]: + """Round facts from a platform action log, streamed line by line. + + Returns ``rounds`` (round → {period_label, headline, simulated_hour}), ``post_keys`` + ((agent_id, content) → rounds in log order), ``content_rounds`` (content → rounds in + log order), ``comment_rounds`` (comment_id → round), ``comment_keys`` ((agent_id, + content) → rounds in log order) and ``truncated``. The round queues are consumed by + ``build_feed_round_index`` in id order. + """ + out: Dict[str, Any] = {"rounds": {}, "post_keys": {}, "content_rounds": {}, "comment_rounds": {}, + "comment_keys": {}, "truncated": False} + if not _regular_file(actions_path): + return out + read = 0 + try: + with open(actions_path, "r", encoding="utf-8") as f: + for line in f: + read += len(line.encode("utf-8", "replace")) + if read > max_bytes: + out["truncated"] = True + break + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict): + continue + rnd = _as_int(row.get("round")) + if rnd is None: + continue + kind = row.get("event_type") or row.get("action_type") + if kind == "round_start": + out["rounds"].setdefault(rnd, {})["simulated_hour"] = row.get("simulated_hour") + continue + if kind == "world_update": + entry = out["rounds"].setdefault(rnd, {}) + if row.get("period_label"): + entry["period_label"] = str(row.get("period_label"))[:40] + if row.get("headline"): + entry["headline"] = str(row.get("headline"))[:300] + continue + if kind not in ("CREATE_POST", "CREATE_COMMENT") or row.get("success") is False: + continue + out["rounds"].setdefault(rnd, {}) + args = row.get("action_args") if isinstance(row.get("action_args"), dict) else {} + content = _norm(args.get("content")) + agent = _as_int(row.get("agent_id")) + if kind == "CREATE_POST": + if content: + out["post_keys"].setdefault((agent, content), deque()).append(rnd) + out["content_rounds"].setdefault(content, deque()).append(rnd) + else: + cid = args.get("comment_id") + if cid not in (None, ""): + out["comment_rounds"].setdefault(str(cid), rnd) + if content: + out["comment_keys"].setdefault((agent, content), deque()).append(rnd) + except OSError: + return {"rounds": {}, "post_keys": {}, "content_rounds": {}, "comment_rounds": {}, + "comment_keys": {}, "truncated": False} + return out + + +def _take(queues: Dict[Any, "deque"], key: Any) -> Optional[int]: + """Pop the next recorded round for ``key`` (None when the key is unknown or exhausted).""" + queue = queues.get(key) + if not queue: + return None + return queue.popleft() + + +def load_platform_rows(db_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[int, int]]: + """(posts, comments, user_id → agent_id) from a platform SQLite file, read-only.""" + if not _regular_file(db_path): + return [], [], {} + uri = "file:" + os.path.abspath(db_path).replace("?", "%3F") + "?mode=ro" + try: + conn = sqlite3.connect(uri, uri=True, timeout=5) + except sqlite3.Error: + return [], [], {} + try: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + try: + posts = [dict(r) for r in cur.execute("SELECT post_id, user_id, content FROM post ORDER BY post_id")] + except sqlite3.Error: + posts = [] + try: + comments = [dict(r) for r in cur.execute( + "SELECT comment_id, post_id, user_id, content FROM comment ORDER BY comment_id")] + except sqlite3.Error: + comments = [] + agents: Dict[int, int] = {} + try: + for r in cur.execute("SELECT user_id, agent_id FROM user"): + uid, aid = _as_int(r["user_id"]), _as_int(r["agent_id"]) + if uid is not None and aid is not None: + agents[uid] = aid + except sqlite3.Error: + pass + return posts, comments, agents + finally: + conn.close() + + +def build_feed_round_index(sim_dir: str, platform: str) -> Dict[str, Any]: + """The index for one platform of a run (empty ``rounds`` when the artefacts are missing).""" + platform = str(platform or "") + actions_path = os.path.join(sim_dir, platform, "actions.jsonl") + db_path = os.path.join(sim_dir, f"{platform}_simulation.db") + acts = read_action_rounds(actions_path) + posts, comments, agents = load_platform_rows(db_path) + post_round: Dict[int, int] = {} + comment_round: Dict[int, int] = {} + unmatched_posts = 0 + unmatched_comments = 0 + rounds_sorted = sorted(acts["rounds"]) + first_round = rounds_sorted[0] if rounds_sorted else 0 + previous = first_round + for post in posts: + pid = _as_int(post.get("post_id")) + if pid is None: + continue + uid = _as_int(post.get("user_id")) + agent = agents.get(uid, uid) + content = _norm(post.get("content")) + rnd = _take(acts["post_keys"], (agent, content)) + if rnd is None and content and len(acts["content_rounds"].get(content) or ()) == 1: + rnd = _take(acts["content_rounds"], content) + if rnd is None: + rnd = previous + unmatched_posts += 1 + post_round[pid] = rnd + previous = rnd + previous = first_round + for comment in comments: + cid = _as_int(comment.get("comment_id")) + if cid is None: + continue + rnd = acts["comment_rounds"].get(str(cid)) + if rnd is None: + uid = _as_int(comment.get("user_id")) + rnd = _take(acts["comment_keys"], (agents.get(uid, uid), _norm(comment.get("content")))) + if rnd is None: + rnd = previous + unmatched_comments += 1 + comment_round[cid] = rnd + previous = rnd + counts_p: Dict[int, int] = {} + counts_c: Dict[int, int] = {} + for rnd in post_round.values(): + counts_p[rnd] = counts_p.get(rnd, 0) + 1 + for rnd in comment_round.values(): + counts_c[rnd] = counts_c.get(rnd, 0) + 1 + all_rounds = sorted(set(rounds_sorted) | set(counts_p) | set(counts_c)) + rounds = [] + for rnd in all_rounds: + meta = acts["rounds"].get(rnd, {}) + rounds.append({ + "round": rnd, + "period_label": meta.get("period_label") or "", + "headline": meta.get("headline") or "", + "posts": counts_p.get(rnd, 0), + "comments": counts_c.get(rnd, 0), + }) + return { + "platform": platform, + "rounds": rounds, + "post_round": post_round, + "comment_round": comment_round, + "unmatched_posts": unmatched_posts, + "unmatched_comments": unmatched_comments, + "truncated": bool(acts["truncated"]), + } + + +def _stamp(path: str) -> Tuple[int, int]: + try: + st = os.stat(path) + return int(st.st_mtime_ns), int(st.st_size) + except OSError: + return (0, 0) + + +def cached_feed_round_index(sim_dir: str, platform: str) -> Dict[str, Any]: + """``build_feed_round_index`` memoised on the action log's and database's mtime/size.""" + platform = str(platform or "") + key = (os.path.abspath(sim_dir), platform, + _stamp(os.path.join(sim_dir, platform, "actions.jsonl")) + + _stamp(os.path.join(sim_dir, f"{platform}_simulation.db"))) + with _cache_lock: + hit = _cache.get(key) + if hit is not None: + _cache.move_to_end(key) + return hit + index = build_feed_round_index(sim_dir, platform) + with _cache_lock: + _cache[key] = index + while len(_cache) > _CACHE_MAX: + _cache.popitem(last=False) + return index + + +def public_index(index: Dict[str, Any]) -> Dict[str, Any]: + """The index without the per-id maps (what the feed-rounds endpoint returns).""" + return {k: v for k, v in index.items() if k not in ("post_round", "comment_round")} + + +def ids_for_round(index: Dict[str, Any], rnd: int, comments: List[Dict[str, Any]]) -> Tuple[List[int], List[int]]: + """(post ids, comment ids) that belong to round ``rnd``: posts created in it plus the + parents of comments made in it; ``comments`` are rows with comment_id/post_id.""" + comment_ids = sorted(cid for cid, r in index.get("comment_round", {}).items() if r == rnd) + wanted = set(comment_ids) + post_ids = {pid for pid, r in index.get("post_round", {}).items() if r == rnd} + for c in comments: + if _as_int(c.get("comment_id")) in wanted and _as_int(c.get("post_id")) is not None: + post_ids.add(_as_int(c.get("post_id"))) + return sorted(post_ids), comment_ids diff --git a/backend/app/services/forecast_binary_resilience.py b/backend/app/services/forecast_binary_resilience.py new file mode 100644 index 00000000..ed850a80 --- /dev/null +++ b/backend/app/services/forecast_binary_resilience.py @@ -0,0 +1,257 @@ +"""Content-filter-safe binary-forecast extraction (report stage). + +Live run ``pipe_d569f211eb14`` (2026-10-16, glm-5.3): the single 48k-character +extraction prompt for Part-1 binary forecasts was rejected by the provider's +content filter (HTTP 400, code 1301, ``role: user``). No fallback provider was +configured, the caller swallowed the exception, ``binary_count`` stayed 0 and the +publish gate failed the whole report. This module wraps +:func:`forecast_extractor.extract_binary_forecasts` with two honest recoveries: + +1. **Chunked retry.** A content filter rejects the *input*; the sensitive + passages are usually concentrated in a few sections. The dossier is split at + H2 headings into a handful of bounded chunks and each chunk is extracted on its + own; filtered chunks are skipped, the rest are merged and renumbered. +2. **Scenario-partition fallback.** When every chunk is filtered, the binary + propositions are derived deterministically from the already-finalised + scenario partition (P(outcome ∈ scenario) = the scenario probability). Rows + carry ``source="scenario-partition"`` and declare their membership, so the + proposition-consistency audit holds by construction and the quality scorecard + (short of ``min_count``) demotes confidence instead of blocking publication. + +Non-filter exceptions propagate unchanged (the caller keeps its legacy logging). +""" + +from __future__ import annotations + +import logging +import math +import re +from typing import Any, Callable, Dict, List, Optional, Sequence + +from ..utils.llm_client import _is_content_filter +from .forecast_extractor import ( + _binary_key, + _binary_quality, + _normalize_binaries, + extract_binary_forecasts, +) + +logger = logging.getLogger(__name__) + +DEFAULT_CHUNK_CHARS = 14_000 +DEFAULT_MAX_CHUNKS = 4 +RECOVERY_SCHEMA = "binary-extraction-recovery/v1" + + +def _is_chinese(language: Any) -> bool: + text = str(language or "").strip().lower() + return text.startswith(("zh", "chinese", "中文")) + + +def split_markdown_chunks(markdown: str, *, chunk_chars: int = DEFAULT_CHUNK_CHARS, + max_chunks: int = DEFAULT_MAX_CHUNKS) -> List[str]: + """Split a dossier at H2 headings into at most ``max_chunks`` bounded chunks. + + Consecutive sections are packed up to ``chunk_chars``; an oversized single + section is hard-split. When more chunks than ``max_chunks`` result, an evenly + spaced subset that always keeps the first and last chunk is returned, so the + retry still covers the document's head (framing) and tail (conclusions). + """ + text = str(markdown or "") + if not text.strip(): + return [] + chunk_chars = max(2_000, int(chunk_chars)) + parts = re.split(r"(?m)^(?=## )", text) + sections = [p for p in parts if p.strip()] + chunks: List[str] = [] + current = "" + for section in sections: + while len(section) > chunk_chars: + if current: + chunks.append(current) + current = "" + chunks.append(section[:chunk_chars]) + section = section[chunk_chars:] + if current and len(current) + len(section) > chunk_chars: + chunks.append(current) + current = section + else: + current += section + if current.strip(): + chunks.append(current) + if max_chunks > 0 and len(chunks) > max_chunks: + if max_chunks == 1: + return [chunks[0]] + step = (len(chunks) - 1) / float(max_chunks - 1) + picked = sorted({int(round(i * step)) for i in range(max_chunks)}) + chunks = [chunks[i] for i in picked] + return chunks + + +def scenario_partition_binaries(scenarios: Optional[Sequence[Dict[str, Any]]], *, + horizon_date: Optional[str] = None, + language: str = "English") -> List[Dict[str, Any]]: + """Deterministic binary rows: one per scenario, P = the scenario probability.""" + rows: List[Dict[str, Any]] = [] + horizon = str(horizon_date or "").strip() + year: Optional[int] = None + if horizon: + m = re.match(r"(\d{4})", horizon) + if m: + year = int(m.group(1)) + zh = _is_chinese(language) + for row in scenarios or []: + if not isinstance(row, dict): + continue + name = str(row.get("name") or "").strip() + raw_prob = row.get("probability") + try: + prob = float(raw_prob) if raw_prob is not None else float("nan") + except (TypeError, ValueError): + continue + if not name or not math.isfinite(prob): + continue + criteria = str(row.get("resolution_criteria") or "").strip() + if zh: + when = f"到 {horizon}" if horizon else "到预测判定日" + statement = f"{when},实际结果落入情景「{name}」。" + else: + when = f"By {horizon}" if horizon else "By the forecast horizon" + statement = f'{when}, the realized outcome falls within the scenario "{name}".' + rows.append({ + "statement": statement, + "probability": round(max(0.02, min(0.98, prob)), 2), + "resolution_criteria": criteria or ( + f"情景「{name}」的判定标准成立" if zh else f'The resolution criteria of scenario "{name}" are met' + ), + "resolution_source": "scenario partition", + "theme": "scenario", + "horizon_year": year, + "base_rate_anchor": str(row.get("base_rate_anchor") or ""), + "adjustment_rationale": str(row.get("adjustment_rationale") or ""), + "source": "scenario-partition", + "scenario_membership": {"derivable": True, "yes_scenarios": [name]}, + }) + return rows + + +def _merge_rows(target: List[Dict[str, Any]], extra: Sequence[Dict[str, Any]]) -> None: + seen = {_binary_key(str(b.get("statement") or "")) for b in target} + for row in extra: + if not isinstance(row, dict): + continue + key = _binary_key(str(row.get("statement") or "")) + if key and key not in seen: + seen.add(key) + target.append(dict(row)) + + +def _renumber(rows: List[Dict[str, Any]]) -> None: + for index, row in enumerate(rows, start=1): + row["id"] = f"F{index}" + + +def _error_brief(exc: Exception) -> str: + return f"{type(exc).__name__}: {str(exc)[:200]}" + + +def extract_binary_forecasts_resilient(report_markdown: str, llm: Any, *, + min_count: int = 10, + chunk_chars: int = DEFAULT_CHUNK_CHARS, + max_chunks: int = DEFAULT_MAX_CHUNKS, + extractor: Optional[Callable[..., Dict[str, Any]]] = None, + **kwargs: Any) -> Dict[str, Any]: + """``extract_binary_forecasts`` with chunked retry and a scenario fallback. + + Returns the extractor's result shape (``binary_forecasts``, ``binary_quality``, + …) plus a ``recovery`` record whenever a recovery path produced the rows. + Only a provider content-filter rejection triggers recovery; every other + exception propagates to the caller unchanged. + """ + run = extractor or extract_binary_forecasts + result: Any = None + primary_error: Optional[Exception] = None + try: + result = run(report_markdown, llm, min_count=min_count, **kwargs) + if isinstance(result, dict) and result.get("binary_forecasts"): + return result + except Exception as exc: # noqa: BLE001 — only content filters are recoverable here + if not _is_content_filter(exc): + raise + primary_error = exc + logger.warning("binary extraction content-filtered by the provider; retrying in %d-char chunks: %s", + chunk_chars, _error_brief(exc)) + if primary_error is None: + # The extractor answered but produced nothing: that is not a filter event, keep legacy behaviour. + return result if isinstance(result, dict) else {"binary_forecasts": [], "binary_quality": {}} + + chunks = split_markdown_chunks(report_markdown, chunk_chars=chunk_chars, max_chunks=max_chunks) + merged: List[Dict[str, Any]] = [] + base: Dict[str, Any] = {} + filtered = 0 + failed = 0 + per_chunk_min = max(3, int(math.ceil(float(min_count) / max(1, len(chunks))))) + for chunk in chunks: + try: + part = run(chunk, llm, min_count=per_chunk_min, **kwargs) + except Exception as exc: # noqa: BLE001 — a filtered or failed chunk is skipped, never fatal + if _is_content_filter(exc): + filtered += 1 + else: + failed += 1 + logger.warning("binary extraction chunk failed (skipped): %s", _error_brief(exc)) + continue + if not isinstance(part, dict): + continue + rows = part.get("binary_forecasts") or [] + if rows and not base: + base = {k: v for k, v in part.items() if k not in ("binary_forecasts", "binary_quality")} + _merge_rows(merged, rows) + extra_mc = (part.get("market_comparison") or {}).get("comparisons") if isinstance(part.get("market_comparison"), dict) else None + if extra_mc and isinstance(base.get("market_comparison"), dict): + existing = base["market_comparison"].setdefault("comparisons", []) + if isinstance(existing, list): + existing.extend(c for c in extra_mc if c not in existing) + themes = kwargs.get("themes") + if merged: + _renumber(merged) + quality = _binary_quality(merged, min_count=min_count, themes_expected=themes) + recovery = { + "schema_version": RECOVERY_SCHEMA, + "mode": "chunked_after_content_filter", + "chunks": len(chunks), + "filtered_chunks": filtered, + "failed_chunks": failed, + "primary_error": _error_brief(primary_error), + "rows": len(merged), + } + logger.warning("binary extraction recovered from a content filter: %d rows from %d/%d chunks " + "(%d filtered, %d failed)", len(merged), len(chunks) - filtered - failed, + len(chunks), filtered, failed) + return {**base, "binary_forecasts": merged, "binary_quality": quality, "recovery": recovery} + + scenario_rows = scenario_partition_binaries( + kwargs.get("scenarios"), horizon_date=kwargs.get("horizon_date"), + language=str(kwargs.get("language") or "English")) + normalized = _normalize_binaries(scenario_rows, language=str(kwargs.get("language") or "English")) + _renumber(normalized) + quality = _binary_quality(normalized, min_count=min_count, themes_expected=themes) + quality["passed"] = False + issues = list(quality.get("issues") or []) + issues.append( + "二元预测由情景分区确定性派生:提供方对全部抽取尝试触发内容过滤(binary forecasts derived " + "from the scenario partition after the provider content-filtered every extraction attempt)" + ) + quality["issues"] = issues + recovery = { + "schema_version": RECOVERY_SCHEMA, + "mode": "scenario_partition_fallback", + "chunks": len(chunks), + "filtered_chunks": filtered, + "failed_chunks": failed, + "primary_error": _error_brief(primary_error), + "rows": len(normalized), + } + logger.warning("binary extraction fell back to %d scenario-partition propositions after %d/%d chunks were " + "content-filtered", len(normalized), filtered, len(chunks)) + return {"binary_forecasts": normalized, "binary_quality": quality, "recovery": recovery} diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index 5acaae6d..09262075 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -193,7 +193,7 @@ ## 核心任务背景 -我们正在围绕一个具体的**预测问题(central question)**构建知识图谱与推演系统。该问题可能属于任意领域: +我们正在围绕一个具体的**预测问题(central question)**构建知识图谱与推演系统。推演是一个**随时间演化的多主体世界**:每个能动主体在每个日历时段做出有代价、有约束、有时滞的真实行动(政策/投资/结盟/诉讼/施压/观望),世界状态随之改变并反馈给所有主体。因此能动实体类型要体现其**行动杠杆、决策权与约束**,而非只体现「会发声」。该问题可能属于任意领域: - 市场/资产价格走势(如某资产、汇率、商品价格在某时点的区间) - 地缘政治/政策(如某项谈判、选举、制裁、监管裁决的结果) - 产品/技术发布与采用(如某产品能否如期发布、市场反应、份额变化) @@ -318,6 +318,8 @@ - `role_class`: 默认角色类(可选):`principal`(主事方)、`arbiter`(裁决方)、`stakeholder`(利益相关方)、`amplifier`(放大/传播方)、`intermediary`(中介方)。 - `selection_rule`: 一句话判定规则,说明何种实体应归入此类型(用于抽取阶段消歧)。 - `anti_examples`: 反例数组,列出**不应**被实例化为此类型的对象。**特别强调**:被引用的媒体/报刊/榜单/数据库是 `source`(archetype=source, tier=3),不是能动 actor——除非它本身就是推动结局的当事方,否则不要把它设成一个独立的能动实体类型。 +- `agency_class`(仅 archetype=actor/collective 需填):该类主体在演化世界中真实握有的**行动杠杆类别**,必须取自——`sovereign_government`(主权政府/部委)、`regulator_agency`(监管/执法机构)、`legislature`(立法机关/议员)、`central_bank`、`judiciary`、`political_party`、`firm`(企业)、`state_owned_enterprise`、`financial_institution`、`industry_coalition`(行业协会/联盟)、`labor_union`、`ngo_advocacy`、`international_org`、`research_institution`、`individual_leader`(以个人身份行动的领导者/意见人物)、`public_collective`(公众/消费者/选民群体)、`media_outlet`。 +- `levers`(可选,≤5 项):该类主体可以采取的典型可执行动作(如「加征关税」「开启调查」「追加资本开支」「罢工」),供多智能体推演生成**有约束、有代价、有时滞**的行动选项。 为**每个 edge_type** 额外标注: - `family`: 边的语义家族,取自——`alignment`(结盟/支持,正向)、`antagonism`(对抗/制裁/批评,负向)、`economic`(供应/出资/持股/消费,交易性)、`governance`(监管/裁决)、`dependency`(依赖)、`influence`(影响)、`information`(报道/披露)、`causal`(因果/机制传导,如 CAUSES/ENABLES/CONSTRAINS/TRIGGERS/ACCELERATES,有向)。 @@ -908,6 +910,20 @@ def _normalize_rich_schema(cls, result: Dict[str, Any]) -> None: arch = entity.get("archetype") if not (isinstance(arch, str) and arch.strip()): entity["archetype"] = cls._infer_archetype_from_name(entity.get("name", "")) + # REFLEXIVE WORLD: agency class for agent archetypes (LLM value kept when valid). + if str(entity.get("archetype") or "").strip().lower() in ("actor", "collective"): + klass = entity.get("agency_class") + try: + from .agent_decision import AGENCY_CLASSES, infer_agency_class + if not (isinstance(klass, str) and klass.strip().lower() in AGENCY_CLASSES): + entity["agency_class"] = infer_agency_class( + entity.get("name", ""), entity.get("description", ""), + archetype=str(entity.get("archetype") or ""), + ) + else: + entity["agency_class"] = klass.strip().lower() + except Exception: # noqa: BLE001 — additive metadata only + pass for edge in result.get("edge_types", []): if not isinstance(edge, dict): diff --git a/backend/app/services/pipeline_orchestrator.py b/backend/app/services/pipeline_orchestrator.py index a702156f..a2edaaed 100644 --- a/backend/app/services/pipeline_orchestrator.py +++ b/backend/app/services/pipeline_orchestrator.py @@ -11317,6 +11317,20 @@ def _log_decision_channel_outcome(self, state: PipelineState, simulation_id: Opt "leader_share": (outcome.get("leader_share") if isinstance(outcome, dict) else None), "converged_at": converged_at, } + # REFLEXIVE WORLD: did the world actually evolve (rounds committed, variables moved, + # arbiter failures)? Read from world_state.json; absent for legacy / hours runs. + _wm = _read_json(os.path.join(sim_dir, "world_state.json")) + if isinstance(_wm, dict) and isinstance(_wm.get("summary"), dict): + _wms = _wm["summary"] + summary["world_model"] = { + key: _wms.get(key) for key in ( + "rounds_committed", "variables", "events", "relationships", + "arbiter_enabled", "arbiter_failed_rounds") + } + summary["world_headline"] = str(_wm.get("headline") or "")[:200] + logger.info("[%s] 反身世界模型:已提交 %s 个时段,%s 个变量,%s 条事件,仲裁失败 %s 轮——%s", + state.pipeline_id, _wms.get("rounds_committed"), _wms.get("variables"), + _wms.get("events"), _wms.get("arbiter_failed_rounds"), summary["world_headline"]) state.options["decision_channel_summary"] = summary try: PipelineManager.save(state) diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index 6057afa4..14669c1d 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -27,6 +27,7 @@ from ..config import Config from ..utils.atomic import write_text_atomic, write_json_atomic from ..utils.llm_client import LLMClient, llm_call_timeout +from . import report_filter_safe as _filter_safe from ..utils.logger import get_logger # EXECPLAN2 I-5-4: 报告阶段把 LLM 计量上下文设到 (report_id, 'report'),并按章节读取计量快照差值。 from ..utils.telemetry import LLMCache, LLMMeter, set_run_context, get_run_context @@ -552,6 +553,9 @@ class Report: # 部分完成(partial)信息:生成失败(写入占位符)的章节标题。报告整体仍标记 completed, # 但前端据此把卡片标注为「部分完成」。默认空列表 → to_dict 输出 partial=False,与历史一致。 failed_sections: List[str] = field(default_factory=list) + # FILTER-SAFE:提供方内容审查后仍成稿的章节标题 → 处置层级:0=采纳仅因工具下限被退回的合格草稿, + # 1=机构称谓模式(工具下限 1、回包截断)重写,2=无检索工具重写。仅在非空时写入 to_dict(向后兼容)。 + filter_safe_sections: Dict[str, int] = field(default_factory=dict) # EXECPLAN2 I-5-4: 报告级 LLM 成本/时延/工具调用紧凑汇总(per-section + totals)。 # 仅在 LLM_TELEMETRY_ENABLED 且 REPORT_TELEMETRY 同时开启时填充;否则为 None, # to_dict 输出与历史完全一致(向后兼容,前端可据此显示「本报告耗费 ~$X / Y秒 / Z章」)。 @@ -579,6 +583,9 @@ def to_dict(self) -> Dict[str, Any]: "failed_sections": list(self.failed_sections), "partial": len(self.failed_sections) > 0 } + # FILTER-SAFE:仅在有章节走过内容合规写作模式时附加该键(如实披露,向后兼容) + if self.filter_safe_sections: + d["filter_safe_sections"] = dict(self.filter_safe_sections) # EXECPLAN2 I-5-4: 仅在有遥测数据时附加该键,避免给历史 reader 引入空字段 if self.telemetry is not None: d["telemetry"] = self.telemetry @@ -2347,6 +2354,8 @@ def __init__( # EXECPLAN2 I-5-4: 当前章节的工具调用计数器(_execute_tool 单一汇聚点累加), # 用于 per-section 遥测;未开遥测时该计数依旧无害地维护,开销可忽略。 self._section_tool_calls = 0 + # FILTER-SAFE:走过内容合规写作模式的章节 → 阶梯层级(并发章节线程共享,写入加锁)。 + self._filter_safe_sections: Dict[str, int] = {} # RQ-1(4): 依据需求书 page_budget 收敛/展开本次报告的每章工具预算——小 page_budget 报告回到 # 紧凑 8 次,无/大预算用展开默认(Config.REPORT_AGENT_MAX_TOOL_CALLS=12)。以实例属性覆盖类 @@ -3720,6 +3729,15 @@ def _build_signal_pack(self) -> str: parts.append(ws_blk) except Exception as e: # noqa: BLE001 logger.warning(f"信号包 world_state 读取失败(忽略): {e}") + # 0a) REFLEXIVE WORLD: the committed world's evolution (variable trajectories, decisive + # moves, alliance shifts) — elicited model projection like the shares above; it can + # ground mechanism analysis but never probabilities (diagnostic_only). + try: + we_blk = self._world_evolution_block() + if we_blk: + parts.append(we_blk) + except Exception as e: # noqa: BLE001 + logger.warning(f"信号包 world_evolution 读取失败(忽略): {e}") # 0b) NEXTSTEPS P3-8: 关系演化投影到预测时点(保守模型先验,显式标注=非证据)。 # 默认关(REPORT_PROJECTED_EDGES);标注 contingent 的纽带是情景分叉支点。 if getattr(Config, "REPORT_PROJECTED_EDGES", False): @@ -3865,6 +3883,129 @@ def _world_state_block(self) -> str: lines.append("注:这是结构化情景分析先验,不是观察事实;正文结论必须以研究来源和现实指标校验。") return "\n".join(lines) + def _world_evolution_block(self, max_variables: int = 6, max_moves: int = 8) -> str: + """REFLEXIVE WORLD (design §2.9): render ``world_state.json`` / ``world_state_history.jsonl`` + as a bounded diagnostic block — variable trajectories per period, the decisive moves, + alliance shifts and arbiter failures. Labeled an elicited model projection; absent + artifacts → "" (hours mode / legacy runs are byte-identical).""" + try: + sim_dir = os.path.join(getattr(Config, "OASIS_SIMULATION_DATA_DIR", "") or "", + str(self.simulation_id or "")) + state_path = os.path.join(sim_dir, "world_state.json") + if not os.path.exists(state_path): + return "" + with open(state_path, "r", encoding="utf-8") as f: + state = json.load(f) + except Exception: # noqa: BLE001 + return "" + if not isinstance(state, dict) or not isinstance(state.get("world"), dict): + return "" + world = state["world"] + summary = state.get("summary") if isinstance(state.get("summary"), dict) else {} + rounds = int(state.get("committed_round") or 0) + if rounds <= 0: + return "" + labels = {} + raw_labels = world.get("period_labels") + if isinstance(raw_labels, dict): + for k, v in raw_labels.items(): + try: + labels[int(k)] = str(v) + except (TypeError, ValueError): + continue + + def _lab(r): + try: + return labels.get(int(r)) or f"第{int(r)}期" + except (TypeError, ValueError): + return str(r) + + from .world_model import is_kind_name_unit + + def _fmt(value, unit): + if isinstance(value, bool): + return "是" if value else "否" + if isinstance(value, (int, float)): + av = abs(float(value)) + if av >= 1e9: + s = f"{float(value) / 1e9:.2f}B" + elif av >= 1e6: + s = f"{float(value) / 1e6:.2f}M" + elif float(value).is_integer(): + s = f"{int(value)}" + else: + s = f"{float(value):.2f}".rstrip("0").rstrip(".") + else: + s = str(value)[:60] + u = str(unit or "").strip() + if is_kind_name_unit(u): + u = "" # persisted runs may still carry "boolean" / "date" as a unit + return f"{s}{u}" if u == "%" else (f"{u}{s}" if u and (u[0] in "$€£¥¥") else (f"{s} {u}".strip())) + + lines = ["【世界演化轨迹(elicited model projection——模型引出的推演投影,非观察证据;" + "仅供机制分析,不得据此调整概率)】", + f"已推演 {rounds} 个时段;追踪变量 {summary.get('variables', len(world.get('variables') or []))} 个," + f"世界事件 {summary.get('events', len(world.get('events') or []))} 条"] + failed = int(summary.get("arbiter_failed_rounds") or 0) + if failed: + lines.append(f"⚠️ 其中 {failed} 个时段的仲裁更新失败(仅机械推进),该段轨迹可信度下调") + gaps = int(summary.get("seed_gaps") or 0) + if gaps: + lines.append(f"建模缺口 {gaps} 项(研究未能落实的单位/日期/目标/计划/关系,推演起点据此留白而非臆造)") + behaviour = summary.get("behaviour") if isinstance(summary.get("behaviour"), dict) else {} + if behaviour.get("responsive_share") is not None: + lines.append(f"行为诊断(文本层面):{behaviour.get('rounds_scored', 0)} 个时段中," + f"{behaviour.get('responsive_moves', 0)}/{behaviour.get('moves', 0)} 项行动回应了上期的事实变化或他方行动" + f"(占比 {float(behaviour['responsive_share']) * 100:.0f}%),其中应答要约 {behaviour.get('offers_answered', 0)} 项") + offers = summary.get("offers") if isinstance(summary.get("offers"), dict) else {} + if int(offers.get("opened") or 0): + lines.append(f"要约与协议(须对方应答方成立):提出 {offers.get('opened', 0)},接受 {offers.get('accepted', 0)}," + f"拒绝 {offers.get('rejected', 0)},反要约 {offers.get('countered', 0)}," + f"失效 {offers.get('lapsed', 0)},违约 {offers.get('breached', 0)},仍待答复 {offers.get('open', 0)}") + shown = 0 + for var in world.get("variables") or []: + if not isinstance(var, dict) or shown >= max_variables: + continue + hist = [h for h in (var.get("history") or []) if isinstance(h, dict)] + if len(hist) < 2: + continue + path = " → ".join(f"{_fmt(h.get('value'), var.get('unit'))}({_lab(h.get('round'))})" + if int(h.get("round") or 0) > 0 else _fmt(h.get("value"), var.get("unit")) + for h in hist[-6:]) + lines.append(f"· {var.get('name')}: {path}") + shown += 1 + moves: List[str] = [] + shifts: List[str] = [] + try: + hist_path = os.path.join(sim_dir, "world_state_history.jsonl") + if os.path.exists(hist_path): + with open(hist_path, "r", encoding="utf-8") as f: + for line in f: + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict): + continue + label = str(row.get("period_label") or _lab(row.get("round"))) + for act in ((row.get("deterministic") or {}).get("actions") or [])[:3]: + if "holds position" in str(act) or "no recorded move" in str(act): + continue + moves.append(f"{label}: {str(act)[:120]}") + for rc in ((row.get("diff") or {}).get("relationships_changed") or [])[:2]: + if isinstance(rc, dict): + shifts.append(f"{label}: {rc.get('source')} — {rc.get('target')} → {rc.get('to')}") + except OSError: + pass + if moves: + lines.append("决定性行动:") + lines.extend(f"· {m}" for m in moves[:max_moves]) + if shifts: + lines.append("联盟/对立变动:") + lines.extend(f"· {s}" for s in shifts[:6]) + lines.append("注:世界变量由推演仲裁逐期更新并有界钳制,不是观察到的现实指标;现实世界的定量声明只能来自研究来源。") + return "\n".join(lines) + def _temporal_horizon_date(self) -> str: """日历模式的判定日 horizon_date(轨迹 v3 优先,其次 simulation_config 的 temporal_config);hours 模式/无产物 → ""(调用方回退旧行为,degrade-safe)。""" @@ -4379,9 +4520,13 @@ def _finalize_structured_forecast(self, report_id: str, report_markdown: str, from .forecast_extractor import ( _binary_quality as _binary_quality_score, apply_horizon_consistency as _apply_horizon_consistency, - extract_binary_forecasts as _ebf, reconcile_forecast_contract as _reconcile_forecast_contract, ) + # Content-filter-safe extraction (chunked retry → scenario-partition + # fallback); see forecast_binary_resilience for the live failure it fixes. + from .forecast_binary_resilience import ( + extract_binary_forecasts_resilient as _ebf, + ) _lang = (getattr(self, "output_language", None) or getattr(Config, "DEERFLOW_RESEARCH_LANGUAGE", None) or "English") _bsrc = self.research_report or report_markdown or "" @@ -4451,6 +4596,17 @@ def _finalize_structured_forecast(self, report_id: str, report_markdown: str, themes_expected=_themes, ) _quality["proposition_consistency"] = _contract + if isinstance(_bres.get("recovery"), dict): + # Provenance of a recovered extraction (chunked retry or the + # deterministic scenario-partition fallback) survives the + # scorecard recomputation above and lands in forecast.json. + _quality["recovery"] = dict(_bres["recovery"]) + forecast["binary_extraction_recovery"] = dict(_bres["recovery"]) + if _bres["recovery"].get("mode") == "scenario_partition_fallback": + _quality["passed"] = False + _quality["issues"] = list(_quality.get("issues") or []) + [ + "二元预测由情景分区确定性派生(提供方内容过滤了全部抽取尝试)" + ] forecast["binary_quality"] = _quality # RQ-6:校验二元预测结算年份与真实判定期一致——目标年份集合(需求书 + # 日历 horizon_date.year)与二元结算年份集合非空且无交集时,把 @@ -10472,6 +10628,16 @@ def _rj(path: str) -> Optional[Any]: arts["world_state_trajectory"] = wst except Exception: # noqa: BLE001 pass + # REFLEXIVE WORLD: world_state.json(committed world + variable histories)→ world-variables chart + try: + ws_path = os.path.join( + getattr(Config, "OASIS_SIMULATION_DATA_DIR", "") or "", + str(self.simulation_id or ""), "world_state.json") + wsj = _rj(ws_path) + if isinstance(wsj, dict) and isinstance(wsj.get("world"), dict): + arts["world_state"] = wsj + except Exception: # noqa: BLE001 + pass # timeline.json(研究 handoff;best-effort,找不到即不画时间线) tl = self._locate_timeline() if not tl and isinstance(getattr(self, "timeline_events", None), list) and self.timeline_events: @@ -11267,6 +11433,15 @@ def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]: call_data["name"] = call_data.pop("tool") if "params" in call_data and "parameters" not in call_data: call_data["parameters"] = call_data.pop("params") + if ("arguments" in call_data and "parameters" not in call_data + and isinstance(call_data["arguments"], dict)): + call_data["parameters"] = call_data.pop("arguments") + if "parameters" not in call_data: + # report_62e8219d3990 实测:模型把参数平铺在顶层 {"name": "quick_search", + # "query": "...", "limit": 10} → 旧解析得到空参数、白烧一次检索。顶层非保留键视作参数。 + _flat = {k: v for k, v in call_data.items() if k not in ("name", "tool")} + if _flat: + call_data["parameters"] = _flat if not call_data.get("name"): continue tool_calls.append(call_data) @@ -11646,19 +11821,100 @@ def _generate_section( previous_sections: List[str], progress_callback: Optional[Callable] = None, section_index: int = 0, + filter_safe: int = 0, ) -> str: - """T4.5: 章节生成调度——原生 tool calling 可用时走结构化路径,否则回退手搓 ReAct。""" + """T4.5: 章节生成调度——原生 tool calling 可用时走结构化路径,否则回退手搓 ReAct。 + + FILTER-SAFE:filter_safe>0 时两条路径都以机构称谓写作模式生成;原生路径遇提供方 + 内容审查**不**回退 ReAct(同样的请求内容必被同样拒绝,只会白烧一轮),直接抛给 + _generate_section_with_retry 升级阶梯。""" if self.llm.supports_native_tools(): try: return self._generate_section_native( - section, outline, previous_sections, progress_callback, section_index + section, outline, previous_sections, progress_callback, section_index, + filter_safe=filter_safe, ) except Exception as e: # noqa: BLE001 — 原生路径异常时优雅回退 ReAct,绝不让章节失败 + if _filter_safe.is_content_filter(e): + raise logger.warning(f"原生 tool calling 章节生成失败,回退 ReAct: {e}") return self._generate_section_react( - section, outline, previous_sections, progress_callback, section_index + section, outline, previous_sections, progress_callback, section_index, + filter_safe=filter_safe, ) + # ---- FILTER-SAFE: content-filter retry ladder helpers ---- + _FILTER_SAFE_LOCK = threading.Lock() + + @staticmethod + def _filter_safe_ladder_enabled() -> bool: + return bool(getattr(Config, "REPORT_FILTER_SAFE_LADDER", True)) + + def _accept_draft_under_filter( + self, section: "ReportSection", section_index: int, draft: Optional[str], + tool_calls_count: int, exc: BaseException, + ) -> Optional[str]: + """FILTER-SAFE:检索补足阶段被提供方内容审查拒绝,而此前已有一份仅因工具调用不足被退回、 + 且通过污染检测的完整草稿 → 采纳该草稿(记 filter_safe_sections=0);否则返回 None 让异常照常 + 传播。pipe_d569f211eb14 实测:13k 字草稿先被工具下限退回,随后累积 70k 检索回包触发输入侧 + 审查,整章被钉成占位符、整份报告被拒发。工具下限是质量助推,不是正确性门。""" + if (not draft or not self._filter_safe_ladder_enabled() + or not _filter_safe.is_content_filter(exc)): + return None + logger.warning( + f"章节 {section.title} 在检索补足阶段触发提供方内容审查,采纳此前已通过审查的完整草稿" + f"(工具调用 {tool_calls_count} 次): {str(exc)[:160]}" + ) + self._record_filter_safe_section(section.title, _filter_safe.LEVEL_DRAFT_ACCEPTED) + if self.report_logger: + self.report_logger.log_section_content( + section_title=section.title, section_index=section_index, + content=draft, tool_calls_count=tool_calls_count, + ) + return draft + + def _record_filter_safe_section(self, title: str, level: int) -> None: + """FILTER-SAFE:记下以内容合规写作模式生成的章节(并发安全;无 __init__ 的测试替身也可用)。""" + with self._FILTER_SAFE_LOCK: + table = getattr(self, "_filter_safe_sections", None) + if table is None: + table = {} + self._filter_safe_sections = table + table[title] = max(int(level), int(table.get(title, 0) or 0)) + + def _filter_safe_direct_answer( + self, section: "ReportSection", section_index: int, messages: List[Dict[str, Any]] + ) -> str: + """FILTER-SAFE L2:无工具单次直出。检索回包是提供方输入侧审查的触发源(静态词表无法 + 预判),而背景档案/来源索引/前文已在提示词内;输出仍过污染检测,绝不写入污染正文。""" + messages = list(messages) + messages[-1] = dict(messages[-1]) + messages[-1]["content"] = ( + str(messages[-1].get("content") or "") + + "\n\n本次请勿调用任何工具;直接以 \"Final Answer:\" 开头输出本章完整 Markdown 正文。" + ) + response = self.llm.chat( + messages=messages, + temperature=Config.REPORT_AGENT_TEMPERATURE, + max_tokens=Config.REPORT_AGENT_SECTION_MAX_TOKENS, + ) + if response is None: + raise RuntimeError(f"章节 {section.title} 过滤安全直出时 LLM 返回空响应") + final_answer = ( + response.split("Final Answer:")[-1].strip() if "Final Answer:" in response + else response.strip() + ) + if _looks_contaminated(final_answer): + logger.error(f"章节 {section.title} 过滤安全直出输出被污染/过短,写入失败占位符") + return SECTION_FAILURE_PLACEHOLDER + logger.info(f"章节 {section.title} 以过滤安全模式 L2(无工具)生成完成") + if self.report_logger: + self.report_logger.log_section_content( + section_title=section.title, section_index=section_index, + content=final_answer, tool_calls_count=0, + ) + return final_answer + def _generate_section_with_retry( self, section: ReportSection, @@ -11668,7 +11924,13 @@ def _generate_section_with_retry( section_index: int = 0, ) -> str: """RPT-2: 章节异常先退避重试,再落占位符。瞬时故障(限流/超时/单次 5xx)不应 - 直接把章节钉死成占位符。REPORT_SECTION_RETRY_MAX=0 时与直接调用逐字节等价。""" + 直接把章节钉死成占位符。REPORT_SECTION_RETRY_MAX=0 时与直接调用逐字节等价。 + + FILTER-SAFE:提供方内容审查(HTTP 400 code 1301 / 422 等)是对请求内容的确定性拒绝, + 原样重发只会再被拒(pipe_d569f211eb14 的章节六次全拒,LLM 缓存还让重试逐字节重放)。 + 审查类异常不退避,立即升一级重试:L1 机构称谓写作指令 + 工具下限 1 + 检索回包截断; + L2 另不提供检索工具。非审查异常与 level 0 请求的行为逐字节不变(REPORT_FILTER_SAFE_LADDER=false + 时全部回到旧的原样退避重试)。""" try: retries = int(getattr(Config, "REPORT_SECTION_RETRY_MAX", 1) or 0) except (TypeError, ValueError): @@ -11678,22 +11940,35 @@ def _generate_section_with_retry( except (TypeError, ValueError): backoff = 8.0 attempt = 0 + filter_level = 0 # FILTER-SAFE:内容审查重试阶梯层级(0=常规请求) while True: try: + _gen_kwargs = {"filter_safe": filter_level} if filter_level else {} content = self._generate_section( - section, outline, previous_sections, progress_callback, section_index + section, outline, previous_sections, progress_callback, section_index, + **_gen_kwargs, ) break except Exception as e: # noqa: BLE001 — 重试耗尽后重抛,由调用方落占位符 if attempt >= retries: raise attempt += 1 + if (self._filter_safe_ladder_enabled() and _filter_safe.is_content_filter(e) + and filter_level < _filter_safe.FILTER_SAFE_MAX_LEVEL): + filter_level += 1 + logger.warning( + f"章节 {section.title} 触发提供方内容审查,立即以内容合规写作模式 " + f"{_filter_safe.level_label(filter_level)} 重试 {attempt}/{retries}: {e}" + ) + continue wait_s = min(backoff * attempt, 60.0) logger.warning( f"章节 {section.title} 生成异常,{wait_s:.0f}s 后重试 " f"{attempt}/{retries}: {e}" ) time.sleep(wait_s) + if filter_level: + self._record_filter_safe_section(section.title, filter_level) # WAVE9:章节语言验收(一次重写重申 _lang_override)——先于反思,让反思与 # 后续修复都在目标语言的稿件上进行。degrade-safe:任何失败返回原草稿。 try: @@ -12033,8 +12308,13 @@ def _generate_section_native( previous_sections: List[str], progress_callback: Optional[Callable] = None, section_index: int = 0, + filter_safe: int = 0, ) -> str: - """T4.5: 用原生 tool calling 生成章节(无正则解析/无 conflict_retries/无污染检测)。""" + """T4.5: 用原生 tool calling 生成章节(无正则解析/无 conflict_retries/无污染检测)。 + + FILTER-SAFE:filter_safe=1 时加机构称谓写作指令、工具下限降为 1、工具回包截断; + filter_safe=2 时另不提供工具,单次直出。仅因工具下限被退回的合格正文留作草稿:其后的 + 检索补足若被提供方内容审查拒绝,采纳该草稿。level 0 行为逐字节不变。""" logger.info(f"原生 tool calling 生成章节: {section.title}") if self.report_logger: self.report_logger.log_section_start(section.title, section_index) @@ -12044,6 +12324,10 @@ def _generate_section_native( _section_heading = f"{section.title}\n本章内容定位(大纲规划): {section.description}" # LOOP-015: 与 ReAct 路径同源的有效工具调用下限(padding=0 / 证据预注入=1 / 其余=配置值)。 _eff_min = self._effective_min_tool_calls(section) + if filter_safe >= _filter_safe.LEVEL_NO_TOOLS: + _eff_min = 0 + elif filter_safe >= _filter_safe.LEVEL_REGISTER: + _eff_min = min(_eff_min, 1) # FILTER-SAFE L1:一次检索即可成文,少累积输入侧审查面 _prompt_kwargs = self._section_prompt_kwargs() # RQ-1: 篇幅+工具调用范围槽位(随形状伸缩) _prompt_kwargs["min_tool_calls"] = _eff_min system_prompt = self._lang_override() + SECTION_SYSTEM_PROMPT_TEMPLATE.format( @@ -12064,11 +12348,15 @@ def _generate_section_native( "不要输出 JSON 工具包裹)。撰写正文前至少调用 " f"{_eff_min} 次工具以获取实证。" ) + if filter_safe: + system_prompt += _filter_safe.register_directive(filter_safe) if previous_sections: # RQ-7: 前序章节切片从固定 [:8000] 升级为按提供方窗口预算化(大窗口携带全量前文, # 小窗口守住 8000 floor)。ADAPTIVE_CONTEXT 关闭时 _cap 恒为 8000(行为不变)。 _cap = self._prior_section_char_budget(8000, len(previous_sections)) + if filter_safe >= 2: + _cap = min(_cap, _filter_safe.PRIOR_SECTION_CHARS_AT_LEVEL_2) # FILTER-SAFE L2:收紧前文切片 previous_content = "\n\n---\n\n".join( (sec[:_cap] + "..." if len(sec) > _cap else sec) for sec in previous_sections ) @@ -12083,10 +12371,13 @@ def _generate_section_native( {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] + if filter_safe >= 2: + return self._filter_safe_direct_answer(section, section_index, messages) schemas = self._to_openai_tool_schemas() max_iterations = 14 # RQ-1: 10→14,支撑更多工具轮次 + 更长章节的收尾 max_tool_calls = self.MAX_TOOL_CALLS_PER_SECTION tool_calls_count = 0 + draft_answer: Optional[str] = None # FILTER-SAFE:仅因工具下限被退回的合格正文 for _ in range(max_iterations): # REPORT-9: 工具调用未达下限时,本回合的正文会被拒绝(强制继续检索)→ 必为「工具决策回合」, @@ -12097,10 +12388,17 @@ def _generate_section_native( if tool_calls_count >= _eff_min else getattr(Config, "REPORT_AGENT_TOOL_TURN_MAX_TOKENS", 8192) ) - resp = self.llm.chat_with_tools( - messages, schemas, temperature=Config.REPORT_AGENT_TEMPERATURE, - max_tokens=_turn_max_tokens - ) + try: + resp = self.llm.chat_with_tools( + messages, schemas, temperature=Config.REPORT_AGENT_TEMPERATURE, + max_tokens=_turn_max_tokens + ) + except Exception as _tool_exc: # noqa: BLE001 — FILTER-SAFE:内容审查且已有合格草稿 → 采纳草稿 + _accepted = self._accept_draft_under_filter( + section, section_index, draft_answer, tool_calls_count, _tool_exc) + if _accepted is not None: + return _accepted + raise calls = resp.get("tool_calls") or [] content = resp.get("content") or "" @@ -12138,7 +12436,8 @@ def _generate_section_native( ) except Exception: pass - messages.append({"role": "tool", "tool_call_id": c["id"], "content": str(result)[:8000]}) + messages.append({"role": "tool", "tool_call_id": c["id"], + "content": _filter_safe.cap_observation(str(result)[:8000], filter_safe)}) # RPT-11: 原生路径此前从不写 log_tool_result,agent_log.jsonl 丢失整条证据链。 if self.report_logger: try: @@ -12156,6 +12455,8 @@ def _generate_section_native( if content.strip(): # EXECPLAN2 F-7-2 工具调用不足且仍可继续检索 → 拒绝过早出正文,强制补足实证(对齐 ReAct 路径) if tool_calls_count < _eff_min and tool_calls_count < max_tool_calls: + if not _looks_contaminated(content.strip()): + draft_answer = content.strip() # FILTER-SAFE:留作内容审查时的兜底草稿 messages.append({"role": "assistant", "content": content}) messages.append({ "role": "user", @@ -12171,10 +12472,17 @@ def _generate_section_native( messages.append({"role": "user", "content": "请基于以上工具结果直接输出本章完整 Markdown 正文。"}) # 兜底:迭代用尽仍无正文 → 末次无工具强制出文 - final = self.llm.chat(messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt + "\n\n请直接输出本章 Markdown 正文。"}, - ], temperature=Config.REPORT_AGENT_TEMPERATURE, max_tokens=Config.REPORT_AGENT_SECTION_MAX_TOKENS) + try: + final = self.llm.chat(messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt + "\n\n请直接输出本章 Markdown 正文。"}, + ], temperature=Config.REPORT_AGENT_TEMPERATURE, max_tokens=Config.REPORT_AGENT_SECTION_MAX_TOKENS) + except Exception as _final_exc: # noqa: BLE001 — FILTER-SAFE:内容审查且已有合格草稿 → 采纳草稿 + _accepted = self._accept_draft_under_filter( + section, section_index, draft_answer, tool_calls_count, _final_exc) + if _accepted is not None: + return _accepted + raise return final def _generate_section_react( @@ -12183,10 +12491,15 @@ def _generate_section_react( outline: ReportOutline, previous_sections: List[str], progress_callback: Optional[Callable] = None, - section_index: int = 0 + section_index: int = 0, + filter_safe: int = 0, ) -> str: """ 使用ReACT模式生成单个章节内容 + + FILTER-SAFE:filter_safe=1 时加机构称谓写作指令、工具下限降为 1、Observation 截断; + filter_safe=2 时另不调用工具,单次直出。仅因工具下限被退回的合格 Final Answer 留作草稿: + 其后的检索补足若被提供方内容审查拒绝,采纳该草稿。level 0 行为逐字节不变。 ReACT循环: 1. Thought(思考)- 分析需要什么信息 @@ -12219,6 +12532,10 @@ def _generate_section_react( # LOOP-015: 有效工具调用下限先于提示词计算——padding / 证据预注入章节的更低下限 # 直接写进提示词槽位,与循环闸门保持一致(提示词不再逼模型凑满配置下限)。 min_tool_calls = self._effective_min_tool_calls(section) + if filter_safe >= _filter_safe.LEVEL_NO_TOOLS: + min_tool_calls = 0 + elif filter_safe >= _filter_safe.LEVEL_REGISTER: + min_tool_calls = min(min_tool_calls, 1) # FILTER-SAFE L1:一次检索即可成文 _prompt_kwargs = self._section_prompt_kwargs() # RQ-1: 篇幅+工具调用范围槽位(随形状伸缩) _prompt_kwargs["min_tool_calls"] = min_tool_calls system_prompt = self._lang_override() + SECTION_SYSTEM_PROMPT_TEMPLATE.format( @@ -12233,6 +12550,8 @@ def _generate_section_react( # T4.1: 钉入研究背景档案 + 来源索引,让每章撰写复用真实角色/关系/时间线并按 [S#] 引用。 system_prompt = self._prepend_research_background(system_prompt, section_title=section.title) + if filter_safe: + system_prompt += _filter_safe.register_directive(filter_safe) # 构建用户prompt - 每个已完成章节各传入最大4000字 if previous_sections: @@ -12241,6 +12560,8 @@ def _generate_section_react( # 模型携带全量前文(避免长研究报告被切碎),小窗口守住 8000 floor(避免重复、保持连贯)。 # ADAPTIVE_CONTEXT 关闭时 _cap 恒为 8000(行为与历史逐字节一致)。 _cap = self._prior_section_char_budget(8000, len(previous_sections)) + if filter_safe >= 2: + _cap = min(_cap, _filter_safe.PRIOR_SECTION_CHARS_AT_LEVEL_2) # FILTER-SAFE L2:收紧前文切片 for sec in previous_sections: truncated = sec[:_cap] + "..." if len(sec) > _cap else sec previous_parts.append(truncated) @@ -12258,6 +12579,8 @@ def _generate_section_react( {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ] + if filter_safe >= 2: + return self._filter_safe_direct_answer(section, section_index, messages) # ReACT循环(min_tool_calls 在提示词构建前经 _effective_min_tool_calls 计算, # T4.4 配置下限 + LOOP-015 padding/证据预注入下调) @@ -12267,6 +12590,9 @@ def _generate_section_react( contamination_retries = 0 # 输出被污染(系统提示泄漏/工具调用残留)的连续重试次数 MAX_CONTAMINATION_RETRIES = 2 # 污染输出最多纠正重试次数 used_tools = set() # 记录已调用过的工具名 + # FILTER-SAFE:仅因工具调用不足被退回、且通过污染检测的完整 Final Answer。检索补足阶段若被 + # 提供方内容审查拒绝,采纳该草稿而非把章节钉成占位符(见 _accept_draft_under_filter)。 + draft_answer: Optional[str] = None # REPORT-8: interview_agents 仍可用(保留在 self.tools / 提示词中),但不再进入 # 「未使用工具」推荐集——采访依赖 OASIS 在线、延迟高且易超时,不应被反复 nudge 去尝试。 all_tools = {"insight_forge", "panorama_search", "quick_search", @@ -12296,11 +12622,18 @@ def _generate_section_react( if tool_calls_count >= min_tool_calls else getattr(Config, "REPORT_AGENT_TOOL_TURN_MAX_TOKENS", 8192) ) - response = self.llm.chat( - messages=messages, - temperature=Config.REPORT_AGENT_TEMPERATURE, - max_tokens=_turn_max_tokens - ) + try: + response = self.llm.chat( + messages=messages, + temperature=Config.REPORT_AGENT_TEMPERATURE, + max_tokens=_turn_max_tokens + ) + except Exception as _chat_exc: # noqa: BLE001 — FILTER-SAFE:内容审查且已有合格草稿 → 采纳草稿 + _accepted = self._accept_draft_under_filter( + section, section_index, draft_answer, tool_calls_count, _chat_exc) + if _accepted is not None: + return _accepted + raise # 检查 LLM 返回是否为 None(API 异常或内容为空) if response is None: @@ -12371,6 +12704,9 @@ def _generate_section_react( if has_final_answer: # 工具调用次数不足,拒绝并要求继续调工具 if tool_calls_count < min_tool_calls: + _candidate = response.split("Final Answer:")[-1].strip() + if not _looks_contaminated(_candidate): + draft_answer = _candidate # FILTER-SAFE:留作内容审查时的兜底草稿 messages.append({"role": "assistant", "content": response}) unused_tools = all_tools - used_tools unused_hint = f"(这些工具还未使用,推荐用一下他们: {', '.join(unused_tools)})" if unused_tools else "" @@ -12458,11 +12794,11 @@ def _generate_section_react( iteration=iteration + 1 ) - result = self._execute_tool( + result = _filter_safe.cap_observation(self._execute_tool( call["name"], call.get("parameters", {}), report_context=report_context - ) + ), filter_safe) if self.report_logger: self.report_logger.log_tool_result( @@ -12551,11 +12887,18 @@ def _generate_section_react( # REPORT-9: 这是强制收尾的「最终答案回合」,保留完整 SECTION_MAX_TOKENS 预算以免截断正文。 # REPORT-10: 温度读 Config.REPORT_AGENT_TEMPERATURE(默认 0.5,行为不变)。 - response = self.llm.chat( - messages=messages, - temperature=Config.REPORT_AGENT_TEMPERATURE, - max_tokens=Config.REPORT_AGENT_SECTION_MAX_TOKENS - ) + try: + response = self.llm.chat( + messages=messages, + temperature=Config.REPORT_AGENT_TEMPERATURE, + max_tokens=Config.REPORT_AGENT_SECTION_MAX_TOKENS + ) + except Exception as _force_exc: # noqa: BLE001 — FILTER-SAFE:内容审查且已有合格草稿 → 采纳草稿 + _accepted = self._accept_draft_under_filter( + section, section_index, draft_answer, tool_calls_count, _force_exc) + if _accepted is not None: + return _accepted + raise # 检查强制收尾时 LLM 返回是否为 None if response is None: @@ -13020,6 +13363,8 @@ def generate_report( # failed_sections / partial。状态仍为 completed(前端以 status==='completed' 判定终态), # 由 partial 标记区分「完整」与「部分完成」。无失败章节时为空列表 → 与历史一致。 report.failed_sections = list(failed_section_titles) + # FILTER-SAFE:如实披露哪些章节在提供方内容审查后以机构称谓模式重写(meta.json 可见)。 + report.filter_safe_sections = dict(getattr(self, "_filter_safe_sections", None) or {}) # EXECPLAN2 I-3-0/I-9-1/I-3-1 + NEXTSTEPS P0-1/P2-1/P2-3: 最终化结构化预测。 # 优先采用先于叙事推导的骨架(P0-1),否则从成稿抽取;随后红队自校准(P2-1)+ diff --git a/backend/app/services/report_filter_safe.py b/backend/app/services/report_filter_safe.py new file mode 100644 index 00000000..e457cd0e --- /dev/null +++ b/backend/app/services/report_filter_safe.py @@ -0,0 +1,106 @@ +"""Content-filter-safe section generation (report stage). + +Live run ``pipe_d569f211eb14`` (2026-10-17, glm-5.3 through the coding-plan endpoint): +the chapter "The Actor Architecture: Who Actually Decides the Outcome" was rejected by +the provider's content filter (HTTP 400, code 1301) on all six attempts and the publish +gate refused the whole report. The agent log shows the mechanics: + +* the first write after a 48k-character ``insight_forge`` dump was rejected on the + *output* side (``role: assistant``); +* on later attempts the model produced a complete 13-15k-character ``Final Answer`` that + passed the filter, the ReAct loop **rejected it for having made too few tool calls**, + the model kept retrieving (another 22k-character dump), and the next request was + rejected on the *input* side (``role: user``); +* every retry replayed the identical request (the LLM cache even replayed the same + draft), so backoff could not help. + +Live probes against the same provider (session scratchpad ``probe_filter*.py``) showed +that the chapter brief itself passes, that an institutional-register directive passes, +and that rewriting leaders' names as offices ("China's top leader") is *itself* a +trigger -- so this module does not redact names. It supplies: + +* :data:`LEVEL_DRAFT_ACCEPTED` -- when a content filter interrupts an attempt that + already holds a complete, uncontaminated draft rejected only for the tool-call floor, + the generators return that draft instead of raising (``filter_safe_sections`` = 0); +* :func:`register_directive` -- the system-prompt directive for the retry ladder that + ``ReportAgent._generate_section_with_retry`` climbs after a content-filter rejection + (never on other errors, never on the first attempt): level 1 keeps retrieval but asks + for a neutral institutional register, lowers the tool-call floor to one and caps each + retrieved passage; level 2 writes from the background dossier and the completed + chapters with no retrieval tools (retrieved passages are the input-side trigger). + +The report records which chapters needed which rung (``Report.filter_safe_sections``). +""" + +from __future__ import annotations + +from ..utils.llm_client import _is_content_filter + +#: ``filter_safe_sections`` value: a complete draft rejected only for the tool-call floor was +#: accepted after the provider content-filtered the retrieval that followed it. +LEVEL_DRAFT_ACCEPTED = 0 +#: Retrieval kept; institutional register, tool-call floor lowered to one, passages capped. +LEVEL_REGISTER = 1 +#: No retrieval tools; written from the background dossier and the completed chapters. +LEVEL_NO_TOOLS = 2 +#: Highest ladder level. +FILTER_SAFE_MAX_LEVEL = LEVEL_NO_TOOLS + +#: Per-passage cap for retrieved material injected into the conversation at level >= 1. The +#: live failure accumulated 48k + 22k characters of retrieval dumps before the input filter +#: struck; the twelve passing chapters never carried that much. +OBSERVATION_CHARS_AT_LEVEL_1 = 6_000 + +#: Prior-chapter slice cap at level 2 (the dossier and source index stay in full). +PRIOR_SECTION_CHARS_AT_LEVEL_2 = 4_000 + + +def is_content_filter(exc: BaseException) -> bool: + """True when ``exc`` is a provider content-filter rejection (shared detector).""" + return _is_content_filter(exc) # type: ignore[arg-type] + + +_DIRECTIVE_L1 = ( + "\n\n【机构称谓写作模式(上一次尝试被提供方内容审查拒绝后启用)/ " + "Institutional-register mode (enabled after the provider's content filter rejected the previous attempt)】\n" + "- 以职务或机构指称在任的国家元首与政府首脑,把决策归因到政府、部委、机构与企业层面;" + "保持中性、分析性的语气,转述立场而不逐字引用煽动性语句。\n" + "- Refer to serving heads of state and government by office or institution and attribute decisions " + "to governments, ministries, agencies and firms; keep a neutral, analytical tone and paraphrase " + "positions rather than quoting inflammatory passages verbatim.\n" + "- 证据归属、[S#] 引用与篇幅纪律全部不变。/ Every evidence-attribution, [S#] citation and length rule still applies." +) + +_DIRECTIVE_L2 = ( + "\n- 本次尝试不提供检索工具,也不要发起任何工具调用:仅依据系统提示中的背景档案、来源索引与已完成章节撰写," + "直接输出本章正文;[S#] 只能引用来源索引中已有的编号。\n" + "- No retrieval tools are available in this attempt and you must not issue tool calls: write from the " + "background dossier, the source index and the completed chapters in this prompt and output the chapter " + "directly; cite only [S#] numbers that already appear in the source index." +) + + +def register_directive(level: int) -> str: + """System-prompt directive for ladder ``level`` (empty for level <= 0).""" + if level <= 0: + return "" + text = _DIRECTIVE_L1 + if level >= LEVEL_NO_TOOLS: + text += _DIRECTIVE_L2 + return text + + +def cap_observation(result: object, level: int) -> str: + """Retrieved passage as injected into the conversation: capped at level >= 1, else unchanged.""" + text = str(result if result is not None else "") + if level >= LEVEL_REGISTER and len(text) > OBSERVATION_CHARS_AT_LEVEL_1: + return text[:OBSERVATION_CHARS_AT_LEVEL_1] + "\n…(检索回包已按机构称谓写作模式截断 / passage capped)" + return text + + +def level_label(level: int) -> str: + return { + LEVEL_DRAFT_ACCEPTED: "L0 采纳已通过审查的完整草稿", + LEVEL_REGISTER: "L1 机构称谓/工具下限 1/回包截断", + LEVEL_NO_TOOLS: "L2 机构称谓/无检索工具", + }.get(level, f"L{level}") diff --git a/backend/app/services/report_lint.py b/backend/app/services/report_lint.py index c1d52295..7b3536de 100644 --- a/backend/app/services/report_lint.py +++ b/backend/app/services/report_lint.py @@ -585,8 +585,50 @@ def _looks_like_title_cell(cell: str) -> bool: return capitalized / len(words) >= 0.6 +# Terminal tokens that cannot close a clause: a cell ending on one of these was +# cut mid-phrase by a width limit (same for the Chinese function words). +_DANGLING_TAIL_WORDS = frozenset(( + "a", "an", "the", "and", "or", "nor", "but", "of", "at", "by", "for", "from", + "in", "on", "to", "with", "without", "as", "is", "are", "was", "were", "be", + "been", "being", "has", "have", "had", "will", "would", "shall", "should", + "may", "might", "must", "can", "could", "than", "below", "above", "across", + "through", "near", "over", "under", "into", "onto", "upon", "per", "via", + "vs", "versus", "including", "excluding", "before", "after", "during", + "between", "against", "if", "then", "else", "when", "while", "where", + "的", "与", "及", "和", "或", "在", "对", "从", "由", "为", "于", "其", +)) + + +def _ends_mid_clause(cell: str) -> bool: + """Width-cut signature: unbalanced brackets, an orphan tail after the last + clause boundary, a dangling function word, or a machine-length final token. + + Complete clause lists written without terminal punctuation (LLM table prose + like "(f) the envelope is confirmed at or near the £3.5 billion") end on a + content word after a full segment — not truncation, whatever the length. + """ + if cell.count("(") != cell.count(")") or cell.count("[") != cell.count("]"): + return True + # Balanced parentheticals belong to the clause around them: "pillar (soybeans, + # licences) demonstrably incomplete" ends on a full clause, not a two-word orphan + # after ")". Strip them before locating the final clause segment. + body = re.sub(r"\([^()]*\)|\[[^\[\]]*\]|([^()]*)|【[^【】]*】", " ", cell) + # A period between digits is a decimal ("£3.5"), not a clause boundary. + seg = re.split(r"(?:(? 24: + return True + return False + + def detect_table_cell_truncation(md: str, min_len: int = 160) -> List[str]: - """表格单元疑似截断:超长且以字母/数字结尾(无句末标点)→ 采样标记(不改写)。""" + """表格单元疑似截断:超长、以字母/数字结尾(无句末标点)且呈中断签名 + (悬垂虚词/孤段/括号失衡/超长尾词)→ 采样标记(不改写)。""" out: List[str] = [] lines = (md or "").split("\n") mask = _fence_mask(lines) @@ -598,7 +640,7 @@ def detect_table_cell_truncation(md: str, min_len: int = 160) -> List[str]: for cell in ln.strip().strip("|").split("|"): c = cell.strip() if (len(c) >= min_len and c and c[-1].isalnum() - and not _looks_like_title_cell(c)): + and not _looks_like_title_cell(c) and _ends_mid_clause(c)): out.append(c[-60:]) if len(out) >= 8: return out diff --git a/backend/app/services/report_visualizer.py b/backend/app/services/report_visualizer.py index 8d48c0b5..7f7c81de 100644 --- a/backend/app/services/report_visualizer.py +++ b/backend/app/services/report_visualizer.py @@ -80,6 +80,7 @@ "Comparable Forecast Benchmarks": "可比预测基准", "Forecast Revisions Across Published Vintages": "历次发布版本的预测修订", "Forecast Outcome-Share Trajectory": "预测结果份额轨迹", + "Simulated World Variables (indexed, seed = 100)": "推演世界变量(指数化,种子值 = 100)", "Baseline vs Scenario": "基线与情景对比", "Calibration Curve": "校准曲线", "Market-Implied P(yes) History vs Model": "市场隐含 P(是) 历史与模型对比", @@ -304,6 +305,97 @@ def _quant_panel_subtitles(panels: List[Dict[str, Any]]) -> List[str]: _TIER_WEIGHT = {"S1": 3.0, "S2": 2.0, "S3": 1.0, "S4": 0.5} +def world_variable_series(world: Any, *, max_variables: int = 12) -> List[Dict[str, Any]]: + """REFLEXIVE WORLD: chart series of the numeric world variables of a ``world_state.json`` + ``world`` block, one point per period. + + The seed value sits at the research as-of date (index 100). Each committed period then gets + the LAST value recorded in it — a delayed effect and the arbiter can both write the same + period, which used to draw a vertical pair of points — and a period without a change carries + the previous value forward, so every series spans the whole run and no two points share an x. + A variable the arbiter created in a later period starts at that period. Rows are + ``{"id", "name", "unit", "rounds", "x", "y", "hover", "raw"}``; variables need + ``kind == "numeric"`` and ≥2 numeric history rows, and anything malformed is skipped.""" + if not isinstance(world, dict): + return [] + labels: Dict[int, str] = {} + raw_labels = world.get("period_labels") + if isinstance(raw_labels, dict): + for k, v in raw_labels.items(): + try: + labels[int(k)] = str(v) + except (TypeError, ValueError): + continue + as_of = str(world.get("as_of_date") or "")[:10] + committed = 0 + for key in ("committed_round", "round"): + try: + committed = max(committed, int(world.get(key) or 0)) + except (TypeError, ValueError): + continue + + def _label(rnd: int) -> str: + return labels.get(rnd) or f"period {rnd}" + + out: List[Dict[str, Any]] = [] + for var in (world.get("variables") or [])[:max_variables]: + if not isinstance(var, dict) or var.get("kind") != "numeric": + continue + last_by_round: Dict[int, Dict[str, Any]] = {} + numeric_rows = 0 + for h in var.get("history") or []: + if not isinstance(h, dict): + continue + value = h.get("value") + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + continue + try: + rnd = max(0, int(h.get("round") or 0)) + except (TypeError, ValueError): + continue + numeric_rows += 1 + last_by_round[rnd] = h # later rows of the same period win + if numeric_rows < 2: + continue + first = min(last_by_round) + seed_row = last_by_round[first] + seed = float(seed_row["value"]) + if seed in (0.0, -0.0): + continue # a zero seed cannot be indexed to 100 + unit = str(var.get("unit") or "") + + def _index(raw: float) -> float: + return round(raw / seed * 100.0, 6) # no floating-point noise in the chart data + + def _fmt(raw: float) -> str: + return f"{raw:g} {unit}".strip() + + if first == 0: + x0 = as_of or "seed" + source = str(seed_row.get("source") or "research") + hover0 = _fmt(seed) + f" — seed ({source}" + (f", as of {as_of})" if as_of else ")") + else: + x0 = _label(first) + hover0 = _fmt(seed) + " — " + (str(seed_row.get("cause") or "").strip() or "created") + rounds, xs, ys, hover, raws = [first], [x0], [_index(seed)], [hover0], [seed] + current = seed + for rnd in range(first + 1, max(committed, max(last_by_round)) + 1): + row = last_by_round.get(rnd) + if row is not None: + current = float(row["value"]) + cause = str(row.get("cause") or "").strip() or "changed" + else: + cause = "unchanged" + rounds.append(rnd) + xs.append(_label(rnd)) + ys.append(_index(current)) + hover.append(_fmt(current) + f" — {cause}") + raws.append(current) + out.append({"id": str(var.get("id") or ""), "name": str(var.get("name") or var.get("id") or ""), + "unit": unit, "rounds": rounds, "x": xs, "y": ys, "hover": hover, "raw": raws}) + return out + + def _apply_layout(fig, title: str, height: int = 520, **kwargs) -> None: """统一 plotly 布局:浅色画布 + 细网格 + 固定分类色板 + CJK 安全字体栈。就地修改 fig。""" fig.update_layout( @@ -3106,6 +3198,42 @@ def build_model_vs_market_html(self, forecast: Any, charts_dir: str) -> Optional logger.debug("build_model_vs_market_html 失败(跳过该图):%s", exc) return None + def build_world_variables_html(self, world_state: Any, charts_dir: str) -> Optional[str]: + """REFLEXIVE WORLD: one line per numeric world variable with ≥2 history rows, indexed to + its seed value (= 100) so variables of different units share one axis. The seed sits at + the research as-of date and every committed period gets exactly one point (the last value + recorded in it, carried forward when nothing changed — see ``world_variable_series``); + hover shows the raw value + unit and the cause recorded for that period. No qualifying + variable → None (degrade-safe). Labeled simulated, never observed.""" + if not self._interactive_ok(): + return None + try: + world = world_state.get("world") if isinstance(world_state, dict) else None + series = world_variable_series(world) + if not series: + return None + fig = go.Figure() + axis_by_round: Dict[int, str] = {} + for s in series: + for rnd, x in zip(s["rounds"], s["x"]): + axis_by_round.setdefault(int(rnd), str(x)) + fig.add_trace(go.Scatter( + x=s["x"], y=s["y"], mode="lines+markers", + name=_html_text(s["name"], max_len=40), + text=s["hover"], hovertemplate="%{x}: %{text}" + + _html_text(s["name"], max_len=40) + "", + )) + _apply_layout(fig, "Simulated World Variables (indexed, seed = 100)", height=460) + # categorical x in run order: the as-of seed first, then the periods — independent of + # which series (a research seed or a variable the arbiter added later) is drawn first + fig.update_layout(xaxis_title="Simulated period", yaxis_title="Index (seed value = 100)", + xaxis=dict(type="category", categoryorder="array", + categoryarray=[axis_by_round[r] for r in sorted(axis_by_round)])) + return self._save_pair(fig, charts_dir, "world_variables", "world_variables") + except Exception as exc: # noqa: BLE001 + logger.debug("build_world_variables_html 失败(跳过该图):%s", exc) + return None + def build_worldstate_area_html(self, trajectory: Any, charts_dir: str) -> Optional[str]: """(C3) 结果世界态堆叠面积的交互式 HTML(对应 build_worldstate_area)。 @@ -4984,6 +5112,7 @@ def build_all(self, report_id: str, report_dir: str, graph_priors graph_priors.json({entity: weight}) graph_priors_structural 结构先验(graph_priors 缺失时的回退) world_state_trajectory world_state_trajectory.json(dict) + world_state world_state.json(dict,reflexive world:变量历史 → 指数化折线) comparison comparison.json(dict,matplotlib 专属) calibration 校准统计(matplotlib 专属) market_price_history {market_id:[{t,p}]}(缺则回退读 reports/{id}/ 与 handoff/ 同名文件) @@ -5146,6 +5275,12 @@ def _attempt(item_id: str, source: str, title: str, hint: str, "Forecast Outcome-Share Trajectory", "scenarios", bool(wst), lambda: self.build_worldstate_area_html(wst, charts_dir)) + # REFLEXIVE WORLD: simulated world variables indexed to their seed value. + wsm = artifacts.get("world_state") + _attempt("world_variables", "world_state", + "Simulated World Variables (indexed, seed = 100)", "scenarios", + isinstance(wsm, dict) and bool((wsm.get("world") or {}).get("variables")), + lambda: self.build_world_variables_html(wsm, charts_dir)) # 市场价格历史折线(多图族,逐图登记)。 if not self._price_hist_ok(): diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py index f80b4d0e..b4b058b0 100644 --- a/backend/app/services/simulation_config_generator.py +++ b/backend/app/services/simulation_config_generator.py @@ -246,6 +246,14 @@ class AgentActivityConfig: # 不改变旧行为)。 cadence: str = "sampled" + # REFLEXIVE WORLD (design §2.7): the realistic lever set of this actor and its ontology + # archetype, resolved deterministically at PREPARE (dossier value → ontology → type/role + # inference). ``outcome_power`` is only ever an explicitly declared value (I-3: visibility + # is never reused as power); None stays None so the decision channel treats it as unknown. + agency_class: str = "" + archetype: str = "" + outcome_power: Optional[float] = None + @dataclass class TimeSimulationConfig: @@ -366,6 +374,11 @@ class SimulationParameters: # 世界简报用对应语言的标题。空串 → to_dict() 省略字段(旧配置逐字节不变)。 output_language: str = "" + # REFLEXIVE WORLD (design §2.7): sealed ``world_model`` block (state variables, actor agency, + # relationships, settings). None → to_dict() omits the field and the runner keeps the legacy + # in-band evolution path; hours mode never carries it. + world_model: Optional[Dict[str, Any]] = None + # 生成元数据 generated_at: str = field(default_factory=lambda: datetime.now().isoformat()) generation_reasoning: str = "" # LLM的推理说明 @@ -400,6 +413,8 @@ def to_dict(self) -> Dict[str, Any]: data["temporal_config"] = self.temporal_config if self.output_language: data["output_language"] = self.output_language + if self.world_model: + data["world_model"] = self.world_model return data def to_json(self, indent: int = 2) -> str: @@ -756,6 +771,25 @@ def report_progress(step: int, message: str): except Exception as e: logger.warning(f"同温层关注构建失败(降级跳过,不影响模拟): {e}") + # ========== Reflexive world model seed (design §2.7) ========== + # Calendar mode + SIM_WORLD_MODEL: deterministic ``world_model`` block from the research + # artifacts already in hand (quantitative facts, official snapshot, actor intelligence, + # relationships). Hours mode / flag off / any failure → None (field omitted, legacy path). + world_model_block = None + if temporal_timeline is not None and getattr(Config, "SIM_WORLD_MODEL", True): + try: + world_model_block = self._build_world_model_block( + simulation_requirement, actors, all_agent_configs, actor_context_packs, + temporal_timeline, simulation_id, + ) + if world_model_block: + from .world_model_seed import world_model_seed_summary + reasoning_parts.append(world_model_seed_summary(world_model_block)) + logger.info(world_model_seed_summary(world_model_block)) + except Exception as e: + logger.warning(f"world model seed failed (legacy evolution path keeps running): {e}") + world_model_block = None + # ========== 最后一步: 生成平台配置 ========== report_progress(total_steps, "生成平台配置...") twitter_config = None @@ -795,6 +829,7 @@ def report_progress(step: int, message: str): as_of_date=(str((actors or {}).get("as_of_date")) if isinstance(actors, dict) and actors.get("as_of_date") else None), world_brief=world_brief, output_language=getattr(self, "_output_language", "") or "", + world_model=world_model_block, # TEMPORAL spec §3: beyond_horizon_events/warnings 已在 _build_scheduled_events # 中回填到 timeline,此处一次性序列化(None → to_dict 省略字段)。 temporal_config=(asdict(temporal_timeline) if temporal_timeline is not None else None), @@ -2093,6 +2128,46 @@ def _num(v: Any) -> float: ) return header + "\n" + "\n".join(lines) + def _build_world_model_block( + self, + simulation_requirement: str, + actors: Optional[Dict[str, Any]], + agent_configs: List[AgentActivityConfig], + actor_context_packs: Optional[Dict[str, Dict[str, Any]]], + temporal_timeline: Any, + simulation_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """REFLEXIVE WORLD (design §2.7): the sealed ``world_model`` seed block. + + Deterministic: quantitative facts + the bound official snapshot become state + variables; every non-audience agent gets an agency class, archetype, class + capacity, declared outcome power, documented plans / red lines / goals (canonical + dossiers: validated context-pack rows only) and its valenced relationships. + Returns None when there is nothing to seed (no actors).""" + from .world_model_seed import build_world_model_seed + official_data = None + try: + from .official_data import load_for_simulation + official_data = load_for_simulation(simulation_id) if simulation_id else None + except Exception: # noqa: BLE001 — official enrichment is optional + official_data = None + block = build_world_model_seed( + actors, + [asdict(c) for c in (agent_configs or [])], + question=simulation_requirement, + actor_context_packs=actor_context_packs, + official_data=official_data, + temporal_timeline=temporal_timeline, + audience_entity_type=self.AUDIENCE_ENTITY_TYPE, + max_variables=int(getattr(Config, "SIM_WORLD_MAX_VARIABLES", 24) or 24), + arbiter_enabled=bool(getattr(Config, "SIM_WORLD_ARBITER", True)), + max_change_ratio=float(getattr(Config, "SIM_WORLD_MAX_CHANGE_RATIO", 0.5) or 0.5), + barrier_timeout_s=float(getattr(Config, "SIM_WORLD_BARRIER_TIMEOUT_S", 1800) or 1800), + ) + if not block.get("actors"): + return None + return block + def _build_world_brief( self, simulation_requirement: str, @@ -3409,6 +3484,22 @@ def _generate_agent_configs_batch( config.stance = bucket if config.sentiment_bias == 0.0 and bucket in ("supportive", "opposing"): config.sentiment_bias = 0.3 if bucket == "supportive" else -0.3 + # REFLEXIVE WORLD: agency class / archetype / declared outcome power (deterministic). + try: + from .world_model_seed import resolve_agency_class as _resolve_agency + from ..utils.actors import entity_archetype as _entity_archetype + _actor_row = matched_actors.get(agent_id) + config.agency_class = _resolve_agency( + _actor_row if isinstance(_actor_row, dict) else None, + config.entity_type if not canonical_v1 else ( + str((_actor_row or {}).get("type") or "") if isinstance(_actor_row, dict) else ""), + ) + config.archetype = _entity_archetype(_actor_row) if isinstance(_actor_row, dict) else "actor" + _declared = (_actor_row or {}).get("outcome_power") if isinstance(_actor_row, dict) else None + if isinstance(_declared, (int, float)) and not isinstance(_declared, bool) and float(_declared) >= 0: + config.outcome_power = float(_declared) + except Exception: # noqa: BLE001 — classification is additive metadata + pass configs.append(config) return configs diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index 059f19dd..3420b8ea 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -920,9 +920,8 @@ def start_simulation( if resume_active: cmd.append("--resume") - # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞 - main_log_path = os.path.join(sim_dir, "simulation.log") - main_log_file = open(main_log_path, 'w', encoding='utf-8') + # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞(追加模式,见 _open_main_log) + main_log_file = cls._open_main_log(sim_dir) # 设置子进程环境变量,确保 Windows 上使用 UTF-8 编码 # 这可以修复第三方库(如 OASIS)读取文件时未指定编码的问题 @@ -985,6 +984,22 @@ def start_simulation( return state + @staticmethod + def _open_main_log(sim_dir: str): + """``simulation.log`` as the runner subprocess's stdout/stderr sink: truncated for this + launch, then opened append-only. + + The child's ``SimulationLogManager`` (scripts/action_logger.py) writes the same file + through its own handle. Two non-appending handles each write at a private offset and + overwrite each other's bytes (torn multi-byte characters, a log that greps as binary, the + world-model round lines missing); with O_APPEND every write of either handle lands at the + current end of file, so the runner's records stay intact next to the captured console + output.""" + path = os.path.join(sim_dir, "simulation.log") + with open(path, "w", encoding="utf-8"): + pass + return open(path, "a", encoding="utf-8") + @staticmethod def _read_log_tail(path: str, max_bytes: int = 2000) -> str: """Read a bounded UTF-8 tail without loading an arbitrarily large simulation log.""" @@ -1253,6 +1268,25 @@ def _read_action_log( or _pe > state.current_period_end): state.current_period_end = _pe + # REFLEXIVE WORLD: one committed world update per round (both + # platforms log the same row); keep the latest for the live UI. + elif event_type == "world_update": + try: + _wm_round = int(action_data.get("round") or 0) + except (TypeError, ValueError): + _wm_round = 0 + with state._lock: + _prev = state.options.get("world_model") if isinstance(state.options, dict) else None + if not isinstance(_prev, dict) or int(_prev.get("round") or 0) <= _wm_round: + state.options["world_model"] = { + "round": _wm_round, + "headline": str(action_data.get("headline") or "")[:300], + "period_label": action_data.get("period_label"), + "arbiter_status": action_data.get("arbiter_status"), + "decisions": action_data.get("decisions"), + "facts_changed": action_data.get("facts_changed"), + } + continue action = AgentAction( @@ -1293,6 +1327,12 @@ def _read_action_log( "run_summary.json", "world_state_trajectory.json", "decisions.jsonl", + # REFLEXIVE WORLD: the committed world, its per-round history/decisions and the + # scenario digest are per-run artifacts; a rerun must not append to or reuse them. + "world_state.json", + "world_state_history.jsonl", + "world_actions.jsonl", + "world_digest.jsonl", "emergent_metrics.json", "twitter_emergent_metrics.json", "reddit_emergent_metrics.json", @@ -2267,6 +2307,21 @@ def write_run_summary( summary["llm_health"] = llm_health # RUN-2: 平台级 llm_calls/llm_errors/error_rate if agent_dynamics: summary["agent_dynamics"] = agent_dynamics # RUN-9: 报告端可据 active=false 抑制情绪叙事 + # REFLEXIVE WORLD: the committed world's summary (rounds, variables, events, arbiter + # failures, headline) so the report/health gate can see whether the world actually + # evolved; absent in hours mode / legacy runs (key omitted). + try: + _wsp = os.path.join(cls.RUN_STATE_DIR, simulation_id, "world_state.json") + if os.path.exists(_wsp): + with open(_wsp, encoding="utf-8") as _wf: + _wsj = json.load(_wf) + if isinstance(_wsj, dict) and isinstance(_wsj.get("summary"), dict): + _wm = dict(_wsj["summary"]) + _wm["headline"] = str(_wsj.get("headline") or "")[:300] + _wm["committed_round"] = _wsj.get("committed_round") + summary["world_model"] = _wm + except (OSError, ValueError, TypeError): + pass if communities: summary["communities"] = communities if organic_ratio_warnings: diff --git a/backend/app/services/world_model.py b/backend/app/services/world_model.py new file mode 100644 index 00000000..090325b8 --- /dev/null +++ b/backend/app/services/world_model.py @@ -0,0 +1,1591 @@ +"""Reflexive world-model kernel for the calendar simulation (design §2.3). + +The simulation used to carry one piece of inter-round state: a probability +distribution over the forecast scenarios (``worldstate.WorldState``). Agents +never observed a concrete world, so nothing they did had consequences they could +see. This module is the pure, serialisable world that the round loop now +evolves and shows to every agent: + +* ``StateVariable`` — named facts with a value, unit, kind, provenance and a + bounded causal history (who/what changed it, in which round). +* ``ActorState`` — per-actor capacity, posture, documented plans, red lines, + pending commitments and the last structured move. +* ``Relationship`` — valenced edges (allies, rivals, regulators, suppliers…) + whose polarity drifts with coalition and antagonistic moves. +* ``PendingEffect`` — delayed consequences resolved with a seeded RNG when due. +* ``WorldEvent`` — the per-round record of actions, scheduled events, + consequences, emergent events and resolutions. + +Kernel invariants (design §2.3): + +* I-1 agent-facing text never carries scenario shares; the scenario + distribution is mirrored here for the record only and is never rendered. +* I-2 every variable change carries a cause; arbiter changes are clamped to a + fraction of the current magnitude per round (``max_change_ratio``). +* I-3 unknown outcome power stays unknown; capacity comes from the agency + class, never from visibility. +* I-4 no LLM, no I/O, no ``Config`` access. Randomness is injected. +""" + +from __future__ import annotations + +import hashlib +import math +import re +import unicodedata +from typing import Any, Dict, Iterable, List, Optional, Tuple + +WORLD_MODEL_STATE_VERSION = "world-model-state/v1" + +VARIABLE_KINDS = ("numeric", "categorical", "boolean", "text") +# Type names a model may echo into a variable's ``unit`` field ("yes boolean", +# "2027-03-10 date"). A unit belongs to numeric facts only, and a kind name is never a +# unit: such units are blanked at validation, in the kernel and by ``format_value``. +_KIND_NAME_UNITS = frozenset(VARIABLE_KINDS) | frozenset(( + "number", "num", "int", "integer", "float", "decimal", "bool", "flag", "string", "str", + "category", "enum", "date", "datetime", "timestamp", +)) +PROVENANCES = ("research", "official", "seed", "arbiter", "effect", "action") +VALENCES = ("allied", "adversarial", "transactional", "directional", "neutral") +EVENT_KINDS = ("action", "scheduled", "consequence", "emergent", "resolution", "system", + "offer", "agreement", "breach") +# OFFERS (ported from the world-v1 engine's consent mechanics): a move of one of these +# options aimed at another actor opens an offer that only the recipient's answer can settle. +PROPOSAL_OPTION_IDS = frozenset({"open_talks", "form_alliance", "signal_concession", "sign_agreement", + "partnership_deal", "bargain", "convene", "program_decision"}) +RESPONSE_PREFIXES = ("accept:", "reject:", "counter:", "defer:") +INTERACTION_TTL_PERIODS = 2 # an unanswered offer lapses after this many periods +INTERACTION_MAX_DEFERRALS = 2 # a deferral moves the deadline one period, at most this often +INTERACTION_TERMS_MAX_CHARS = 300 +INTERACTION_LIMIT = 200 +COMMITMENT_STATUSES = ("pending", "due", "fulfilled", "lapsed", "withdrawn") + +DEFAULT_CAPACITY_PER_ROUND = 2 +HISTORY_LIMIT = 48 +EVENT_LIMIT = 400 +DEFAULT_MAX_CHANGE_RATIO = 0.5 +PUBLIC_BRIEF_MAX_CHARS = 2400 +PRIVATE_BRIEF_MAX_CHARS = 900 + +# polarity thresholds that re-label a relationship after a nudge +_ALLIED_AT = 0.25 +_ADVERSARIAL_AT = -0.25 + +_SCALE_WORDS = ( + ("trillion", 1e12), ("tn", 1e12), ("万亿", 1e12), + ("billion", 1e9), ("bn", 1e9), ("亿", 1e8), + ("million", 1e6), ("mn", 1e6), ("m", 1e6), + ("thousand", 1e3), ("k", 1e3), ("万", 1e4), +) +_CURRENCY_PREFIX = re.compile(r"^(?:us\$|usd|eur|gbp|cny|rmb|jpy|hk\$|a\$|c\$|[$€£¥¥]|美元|人民币|元)\s*", + re.IGNORECASE) +_NUMBER_RE = re.compile(r"[-+]?\d[\d,]*(?:\.\d+)?") + + +# --------------------------------------------------------------------------- +# small helpers +# --------------------------------------------------------------------------- + +def _clamp(v: float, lo: float, hi: float) -> float: + return max(lo, min(hi, v)) + + +def is_kind_name_unit(unit: Any) -> bool: + """True when ``unit`` is a type / kind name rather than a unit of measure.""" + return str(unit or "").strip().casefold() in _KIND_NAME_UNITS + + +def _text(value: Any, max_chars: int = 240) -> str: + """Collapse whitespace and bound a free-text value (never raises).""" + if value is None: + return "" + try: + s = " ".join(str(value).split()) + except Exception: # noqa: BLE001 + return "" + if max_chars > 0 and len(s) > max_chars: + return s[: max(0, max_chars - 1)].rstrip() + "…" + return s + + +def _items(values: Any, limit: int = 6, max_chars: int = 200) -> List[str]: + out: List[str] = [] + if isinstance(values, str): + values = [values] + if not isinstance(values, (list, tuple)): + return out + for v in values: + if isinstance(v, dict): + v = v.get("claim") or v.get("text") or v.get("description") or "" + t = _text(v, max_chars) + if t and t.casefold() not in {o.casefold() for o in out}: + out.append(t) + if len(out) >= limit: + break + return out + + +def normalize_label(value: Any) -> str: + """Case/width-insensitive key for names and aliases.""" + s = unicodedata.normalize("NFKC", str(value or "")).casefold() + return " ".join(s.split()) + + +def slugify(name: Any, max_len: int = 64) -> str: + """Stable variable id from a display name (CJK letters are kept).""" + s = unicodedata.normalize("NFKC", str(name or "")).casefold() + out: List[str] = [] + prev_us = False + for ch in s: + if ch.isalnum(): + out.append(ch) + prev_us = False + elif not prev_us: + out.append("_") + prev_us = True + slug = "".join(out).strip("_")[:max_len].strip("_") + if not slug: + slug = "var_" + hashlib.sha1(str(name or "").encode("utf-8")).hexdigest()[:8] + return slug + + +def parse_quantity(raw: Any) -> Tuple[Optional[float], str]: + """Best-effort ``(number, unit)`` from a researched value string. + + Handles currency prefixes, thousands separators, percentage signs and the + common scale words (k/m/bn/trillion, 万/亿/万亿). Returns ``(None, "")`` + when no number is present so the caller can keep the fact as text. + """ + if isinstance(raw, bool): + return None, "" + if isinstance(raw, (int, float)): + if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)): + return None, "" + return float(raw), "" + s = _text(raw, 160) + if not s: + return None, "" + unit = "" + currency = _CURRENCY_PREFIX.match(s) + if currency: + unit = currency.group(0).strip() + s = s[currency.end():] + m = _NUMBER_RE.search(s) + if not m: + return None, "" + try: + number = float(m.group(0).replace(",", "")) + except ValueError: + return None, "" + tail = s[m.end():].strip() + low = tail.casefold() + if low.startswith("%") or low.startswith("percent") or low.startswith("pct"): + return number, "%" + for word, scale in _SCALE_WORDS: + if low.startswith(word) and (len(low) == len(word) or not low[len(word)].isalpha()): + number *= scale + tail = tail[len(word):].strip() + break + tail_unit = _text(tail, 24).strip(" ,;:") + # keep an explicit unit word (GW, tons, units…) but not a sentence fragment + if tail_unit and len(tail_unit.split()) <= 2 and len(tail_unit) <= 16: + unit = (unit + " " + tail_unit).strip() if unit else tail_unit + return number, unit + + +def _fmt_number(v: float) -> str: + av = abs(v) + if av >= 1e12: + return f"{v / 1e12:.2f} trillion" + if av >= 1e9: + return f"{v / 1e9:.2f} billion" + if av >= 1e6: + return f"{v / 1e6:.2f} million" + if av >= 1e4: + return f"{v:,.0f}" + if float(v).is_integer(): + return f"{int(v)}" + return f"{v:.2f}".rstrip("0").rstrip(".") + + +def format_value(value: Any, unit: str = "") -> str: + """Human rendering of a variable value with its unit.""" + if isinstance(value, bool): + s = "yes" if value else "no" + elif isinstance(value, (int, float)): + s = _fmt_number(float(value)) + else: + s = _text(value, 120) + u = _text(unit, 24) + if not u or is_kind_name_unit(u): + return s + if u == "%": + return f"{s}%" + if _is_currency_unit(u): + return f"{u}{s}" + return f"{s} {u}" + + +_CURRENCY_CODES = {"usd", "eur", "gbp", "cny", "rmb", "jpy", "hkd", "aud", "cad", "美元", "人民币", "元"} + + +def _is_currency_unit(unit: str) -> bool: + u = str(unit or "").strip() + if not u: + return False + if any(sym in u for sym in ("$", "€", "£", "¥", "¥")): + return True + return u.casefold() in _CURRENCY_CODES + + +# --------------------------------------------------------------------------- +# entities +# --------------------------------------------------------------------------- + +class StateVariable: + """A named world fact with a bounded causal history (I-2).""" + + def __init__(self, var_id: str, name: str, value: Any, *, unit: str = "", + kind: str = "numeric", as_of: Optional[str] = None, + provenance: str = "seed", rationale: str = "", + history: Optional[List[Dict[str, Any]]] = None) -> None: + self.id = str(var_id) + self.name = _text(name, 140) or self.id + self.kind = kind if kind in VARIABLE_KINDS else "text" + self.value = self._coerce(value) + # a unit belongs to numeric facts only, and a kind name is never a unit + self.unit = "" if self.kind != "numeric" or is_kind_name_unit(unit) else _text(unit, 24) + self.as_of = _text(as_of, 40) or None + self.provenance = provenance if provenance in PROVENANCES else "seed" + self.rationale = _text(rationale, 240) + self.history: List[Dict[str, Any]] = list(history or []) + if not self.history: + self.history.append({"round": 0, "value": self.value, "cause": "seed", + "source": self.provenance}) + + def _coerce(self, value: Any) -> Any: + if self.kind == "numeric": + num, _ = parse_quantity(value) + if num is None: + self.kind = "text" + return _text(value, 160) + return num + if self.kind == "boolean": + if isinstance(value, bool): + return value + low = _text(value, 16).casefold() + if low in ("true", "yes", "1", "是"): + return True + if low in ("false", "no", "0", "否"): + return False + self.kind = "text" + return _text(value, 160) + return _text(value, 160) + + def set_value(self, value: Any, *, round_num: int, source: str, cause: str, + max_change_ratio: Optional[float] = None) -> bool: + """Apply a change with provenance; numeric changes may be clamped. + + Returns True when the stored value actually changed. + """ + new = self._coerce(value) + if self.kind != "numeric": + self.unit = "" # a numeric fact that became text keeps no unit + if self.kind == "numeric" and isinstance(new, (int, float)) and max_change_ratio: + cur = float(self.value) if isinstance(self.value, (int, float)) else 0.0 + cap = abs(cur) * float(max_change_ratio) if cur != 0 else None + if cap is not None and abs(float(new) - cur) > cap: + new = cur + math.copysign(cap, float(new) - cur) + if new == self.value: + return False + self.value = new + self.provenance = source if source in PROVENANCES else self.provenance + self.history.append({"round": int(round_num), "value": new, + "cause": _text(cause, 200), "source": source}) + if len(self.history) > HISTORY_LIMIT: + self.history = self.history[-HISTORY_LIMIT:] + return True + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, "name": self.name, "value": self.value, "unit": self.unit, + "kind": self.kind, "as_of": self.as_of, "provenance": self.provenance, + "rationale": self.rationale, "history": list(self.history), + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "StateVariable": + return cls(d.get("id") or slugify(d.get("name")), d.get("name") or d.get("id"), + d.get("value"), unit=d.get("unit") or "", kind=d.get("kind") or "text", + as_of=d.get("as_of"), provenance=d.get("provenance") or "seed", + rationale=d.get("rationale") or "", history=d.get("history") or None) + + +class ActorState: + """Per-actor world state: capacity, posture, plans, commitments, last move.""" + + def __init__(self, agent_id: int, name: str, *, agency_class: str = "other_actor", + archetype: str = "actor", capacity_per_round: int = DEFAULT_CAPACITY_PER_ROUND, + outcome_power: Optional[float] = None, posture: str = "", + documented_plans: Optional[List[str]] = None, + red_lines: Optional[List[str]] = None, aliases: Optional[List[str]] = None, + goals: Optional[List[str]] = None, incentives: Optional[Dict[str, str]] = None, + reputation: float = 0.5) -> None: + self.agent_id = int(agent_id) + self.name = _text(name, 160) or f"Agent_{agent_id}" + self.agency_class = _text(agency_class, 48) or "other_actor" + self.archetype = _text(archetype, 32) or "actor" + try: + self.capacity_per_round = max(1, int(capacity_per_round)) + except (TypeError, ValueError): + self.capacity_per_round = DEFAULT_CAPACITY_PER_ROUND + self.capacity_used = 0 + self.outcome_power = None + if outcome_power is not None: + try: + self.outcome_power = max(0.0, float(outcome_power)) + except (TypeError, ValueError): + self.outcome_power = None + self.posture = _text(posture, 200) + self.documented_plans = _items(documented_plans, 6, 200) + self.red_lines = _items(red_lines, 4, 160) + self.goals = _items(goals, 4, 160) + self.incentives = {k: _text(v, 220) for k, v in (incentives or {}).items() if _text(v, 220)} + self.aliases = [a for a in _items(aliases, 8, 80)] + self.reputation = _clamp(float(reputation), 0.0, 1.0) + self.commitments: List[Dict[str, Any]] = [] + self.last_action: Optional[Dict[str, Any]] = None + self.irreversible_option_ids: List[str] = [] + + # -- helpers -------------------------------------------------------------- + @property + def capacity_remaining(self) -> int: + return max(0, self.capacity_per_round - self.capacity_used) + + def name_keys(self) -> List[str]: + keys = [normalize_label(self.name)] + keys.extend(normalize_label(a) for a in self.aliases) + return [k for k in keys if k] + + def add_commitment(self, description: str, *, made_round: int, due_round: int, + source: str = "decision") -> Dict[str, Any]: + row = { + "id": f"c{self.agent_id}_{made_round}_{len(self.commitments) + 1}", + "description": _text(description, 220), + "made_round": int(made_round), + "due_round": int(due_round), + "status": "pending", + "source": _text(source, 40), + } + self.commitments.append(row) + if len(self.commitments) > 24: + self.commitments = self.commitments[-24:] + return row + + def pending_commitments(self) -> List[Dict[str, Any]]: + return [c for c in self.commitments if c.get("status") in ("pending", "due")] + + def to_dict(self) -> Dict[str, Any]: + return { + "agent_id": self.agent_id, "name": self.name, "agency_class": self.agency_class, + "archetype": self.archetype, "capacity_per_round": self.capacity_per_round, + "capacity_used": self.capacity_used, "outcome_power": self.outcome_power, + "posture": self.posture, "documented_plans": list(self.documented_plans), + "red_lines": list(self.red_lines), "goals": list(self.goals), + "incentives": dict(self.incentives), "aliases": list(self.aliases), + "reputation": round(self.reputation, 4), "commitments": list(self.commitments), + "last_action": self.last_action, "irreversible_option_ids": list(self.irreversible_option_ids), + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ActorState": + a = cls(d.get("agent_id", 0), d.get("name") or "", agency_class=d.get("agency_class") or "other_actor", + archetype=d.get("archetype") or "actor", + capacity_per_round=d.get("capacity_per_round") or DEFAULT_CAPACITY_PER_ROUND, + outcome_power=d.get("outcome_power"), posture=d.get("posture") or "", + documented_plans=d.get("documented_plans"), red_lines=d.get("red_lines"), + aliases=d.get("aliases"), goals=d.get("goals"), incentives=d.get("incentives"), + reputation=d.get("reputation", 0.5) if isinstance(d.get("reputation"), (int, float)) else 0.5) + try: + a.capacity_used = max(0, int(d.get("capacity_used") or 0)) + except (TypeError, ValueError): + a.capacity_used = 0 + a.commitments = [c for c in (d.get("commitments") or []) if isinstance(c, dict)] + a.last_action = d.get("last_action") if isinstance(d.get("last_action"), dict) else None + a.irreversible_option_ids = [str(x) for x in (d.get("irreversible_option_ids") or []) + if str(x).strip()] + return a + + +class Relationship: + """A valenced, directed edge between two cast members.""" + + def __init__(self, source: int, target: int, *, kind: str = "RELATES_TO", + valence: str = "neutral", polarity: Optional[float] = None, + strength: Optional[float] = None, basis: str = "", + last_changed_round: int = 0, history: Optional[List[Dict[str, Any]]] = None) -> None: + self.source = int(source) + self.target = int(target) + self.kind = _text(kind, 40).upper() or "RELATES_TO" + self.valence = valence if valence in VALENCES else "neutral" + if polarity is None: + polarity = {"allied": 0.4, "adversarial": -0.4, "transactional": 0.15}.get(self.valence, 0.0) + self.polarity = _clamp(float(polarity), -1.0, 1.0) + self.strength = _clamp(float(strength), 0.0, 1.0) if strength is not None else min(1.0, abs(self.polarity) + 0.3) + self.basis = _text(basis, 200) + self.last_changed_round = int(last_changed_round) + self.history: List[Dict[str, Any]] = list(history or []) + + @property + def key(self) -> Tuple[int, int]: + return (self.source, self.target) + + def involves(self, agent_id: int) -> bool: + return agent_id in (self.source, self.target) + + def nudge(self, delta: float, *, round_num: int, cause: str) -> float: + """Move polarity by ``delta`` (bounded), relabel valence, record cause.""" + before = self.polarity + self.polarity = _clamp(self.polarity + _clamp(float(delta), -0.5, 0.5), -1.0, 1.0) + if self.polarity >= _ALLIED_AT: + self.valence = "allied" + elif self.polarity <= _ADVERSARIAL_AT: + self.valence = "adversarial" + elif self.valence in ("allied", "adversarial"): + self.valence = "neutral" + self.strength = _clamp(0.7 * self.strength + 0.3 * min(1.0, abs(self.polarity) + 0.3), 0.0, 1.0) + self.last_changed_round = int(round_num) + self.history.append({"round": int(round_num), "from": round(before, 4), + "to": round(self.polarity, 4), "cause": _text(cause, 160)}) + if len(self.history) > HISTORY_LIMIT: + self.history = self.history[-HISTORY_LIMIT:] + return self.polarity - before + + def to_dict(self) -> Dict[str, Any]: + return { + "source": self.source, "target": self.target, "kind": self.kind, + "valence": self.valence, "polarity": round(self.polarity, 4), + "strength": round(self.strength, 4), "basis": self.basis, + "last_changed_round": self.last_changed_round, "history": list(self.history), + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Relationship": + return cls(d.get("source", 0), d.get("target", 0), kind=d.get("kind") or "RELATES_TO", + valence=d.get("valence") or "neutral", polarity=d.get("polarity"), + strength=d.get("strength"), basis=d.get("basis") or "", + last_changed_round=d.get("last_changed_round") or 0, history=d.get("history")) + + +class PendingEffect: + """A delayed consequence resolved when its round comes.""" + + def __init__(self, effect_id: str, *, due_round: int, description: str, + variable_id: Optional[str] = None, op: str = "set", value: Any = None, + probability: float = 1.0, source: str = "", actor_id: Optional[int] = None, + targets: Optional[List[str]] = None, created_round: int = 0) -> None: + self.id = str(effect_id) + self.due_round = int(due_round) + self.description = _text(description, 220) + self.variable_id = str(variable_id) if variable_id else None + self.op = op if op in ("set", "add", "mul") else "set" + self.value = value + try: + self.probability = _clamp(float(probability), 0.0, 1.0) + except (TypeError, ValueError): + self.probability = 1.0 + self.source = _text(source, 80) + self.actor_id = int(actor_id) if actor_id is not None else None + self.targets = _items(targets, 6, 80) + self.created_round = int(created_round) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, "due_round": self.due_round, "description": self.description, + "variable_id": self.variable_id, "op": self.op, "value": self.value, + "probability": self.probability, "source": self.source, "actor_id": self.actor_id, + "targets": list(self.targets), "created_round": self.created_round, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PendingEffect": + return cls(d.get("id") or "fx", due_round=d.get("due_round") or 0, + description=d.get("description") or "", variable_id=d.get("variable_id"), + op=d.get("op") or "set", value=d.get("value"), + probability=d.get("probability", 1.0), source=d.get("source") or "", + actor_id=d.get("actor_id"), targets=d.get("targets"), + created_round=d.get("created_round") or 0) + + +class Interaction: + """An offer from one actor to another that only the recipient's answer can settle. + + Ported from the world-v1 engine's consent rule: speech cannot create an agreement. A + proposal (``kind="proposal"``) or a counter-offer (``kind="counter"``) stays ``open`` (or + ``deferred``) until the recipient accepts, rejects or counters it, or it lapses after + ``INTERACTION_TTL_PERIODS`` periods. Acceptance binds *both* parties with commitments + (``obligations``: commitment ids, source ``agreement:``); a lapsed obligation or an + explicit withdrawal is a ``breach`` with reputation and relationship consequences. + """ + + def __init__(self, iid: str, *, proposer: int, recipient: int, option_id: str, terms: str, + opened_round: int, expires_round: int, kind: str = "proposal", status: str = "open", + parent_id: str = "", resolved_round: Optional[int] = None, response: str = "", + obligations: Optional[List[str]] = None, deferrals: int = 0) -> None: + self.id = _text(iid, 40) + self.deferrals = max(0, int(deferrals or 0)) + self.proposer = int(proposer) + self.recipient = int(recipient) + self.option_id = _text(option_id, 64) + self.terms = _text(terms, INTERACTION_TERMS_MAX_CHARS) + self.opened_round = int(opened_round) + self.expires_round = int(expires_round) + self.kind = kind if kind in ("proposal", "counter") else "proposal" + self.status = status if status in ("open", "deferred", "accepted", "rejected", "countered", + "lapsed", "breached") else "open" + self.parent_id = _text(parent_id, 40) + self.resolved_round = int(resolved_round) if resolved_round is not None else None + self.response = _text(response, 200) + self.obligations = [str(o) for o in (obligations or []) if str(o)][:4] + + def is_open(self) -> bool: + return self.status in ("open", "deferred") + + def to_dict(self) -> Dict[str, Any]: + return {"id": self.id, "kind": self.kind, "proposer": self.proposer, "recipient": self.recipient, + "option_id": self.option_id, "terms": self.terms, "opened_round": self.opened_round, + "expires_round": self.expires_round, "status": self.status, "parent_id": self.parent_id, + "resolved_round": self.resolved_round, "response": self.response, + "obligations": list(self.obligations), "deferrals": self.deferrals} + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Interaction": + return cls(d.get("id") or "", proposer=d.get("proposer") or 0, recipient=d.get("recipient") or 0, + option_id=d.get("option_id") or "", terms=d.get("terms") or "", + opened_round=d.get("opened_round") or 0, expires_round=d.get("expires_round") or 0, + kind=d.get("kind") or "proposal", status=d.get("status") or "open", + parent_id=d.get("parent_id") or "", resolved_round=d.get("resolved_round"), + response=d.get("response") or "", obligations=d.get("obligations"), + deferrals=d.get("deferrals") or 0) + + +class WorldEvent: + """One dated world event (action, scheduled, consequence, emergent, resolution, offer, agreement, breach).""" + + def __init__(self, round_num: int, kind: str, description: str, *, + actors: Optional[List[str]] = None, targets: Optional[List[str]] = None, + source: str = "", variable_ids: Optional[List[str]] = None, + statement: str = "", option_id: str = "", action_kind: str = "", + period_label: str = "") -> None: + self.round = int(round_num) + self.kind = kind if kind in EVENT_KINDS else "system" + self.description = _text(description, 320) + self.actors = _items(actors, 8, 120) + self.targets = _items(targets, 8, 120) + self.source = _text(source, 60) + self.variable_ids = [str(v) for v in (variable_ids or []) if v][:8] + self.statement = _text(statement, 320) + self.option_id = _text(option_id, 64) + self.action_kind = _text(action_kind, 32) + self.period_label = _text(period_label, 40) + + def to_dict(self) -> Dict[str, Any]: + d = {"round": self.round, "kind": self.kind, "description": self.description, + "actors": list(self.actors), "targets": list(self.targets), "source": self.source, + "variable_ids": list(self.variable_ids)} + if self.statement: + d["statement"] = self.statement + if self.option_id: + d["option_id"] = self.option_id + if self.action_kind: + d["action_kind"] = self.action_kind + if self.period_label: + d["period_label"] = self.period_label + return d + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "WorldEvent": + return cls(d.get("round") or 0, d.get("kind") or "system", d.get("description") or "", + actors=d.get("actors"), targets=d.get("targets"), source=d.get("source") or "", + variable_ids=d.get("variable_ids"), statement=d.get("statement") or "", + option_id=d.get("option_id") or "", action_kind=d.get("action_kind") or "", + period_label=d.get("period_label") or "") + + +# --------------------------------------------------------------------------- +# the world +# --------------------------------------------------------------------------- + +class WorldModel: + """The shared, evolving world every agent observes and acts upon.""" + + def __init__(self, *, question: str = "", as_of_date: Optional[str] = None, + horizon_date: Optional[str] = None, scenarios: Iterable[str] = (), + settings: Optional[Dict[str, Any]] = None) -> None: + self.question = _text(question, 400) + self.as_of_date = _text(as_of_date, 40) or None + self.horizon_date = _text(horizon_date, 40) or None + self.scenarios = [str(s) for s in scenarios if str(s).strip()] + self.settings: Dict[str, Any] = dict(settings or {}) + self.variables: Dict[str, StateVariable] = {} + self.actors: Dict[int, ActorState] = {} + self.relationships: Dict[Tuple[int, int], Relationship] = {} + self.pending_effects: List[PendingEffect] = [] + self.events: List[WorldEvent] = [] + self.round = 0 + self.committed_round = 0 + self.period: Optional[Dict[str, Any]] = None + self.scenario_shares: Dict[str, float] = {} + self.period_labels: Dict[int, str] = {} + self._effect_seq = 0 + self._name_index: Dict[str, int] = {} + # OFFERS: id → Interaction (open offers, agreements and their outcomes) + self.interactions: Dict[str, Interaction] = {} + self._interaction_seq = 0 + # MODELLING GAPS reported by the seed (what the research could not ground) + self.seed_gaps: List[Dict[str, Any]] = [] + + # -- construction ----------------------------------------------------------- + @classmethod + def from_seed(cls, seed: Dict[str, Any], agent_configs: Optional[List[Dict[str, Any]]] = None, + *, scenarios: Iterable[str] = ()) -> "WorldModel": + """Build the initial world from the PREPARE-time ``world_model`` block. + + ``agent_configs`` (``simulation_config.agent_configs``) supplies names for + cast members the seed does not describe, so every agent has an actor state. + """ + seed = seed if isinstance(seed, dict) else {} + world = cls(question=seed.get("question") or "", as_of_date=seed.get("as_of_date"), + horizon_date=seed.get("horizon_date"), scenarios=scenarios or seed.get("scenarios") or (), + settings=seed.get("settings") if isinstance(seed.get("settings"), dict) else None) + world.seed_gaps = [g for g in (seed.get("gaps") or []) if isinstance(g, dict)][:32] + seeded: Dict[int, Dict[str, Any]] = {} + for row in seed.get("actors") or []: + if isinstance(row, dict) and row.get("agent_id") is not None: + try: + seeded[int(row["agent_id"])] = row + except (TypeError, ValueError): + continue + for cfg in agent_configs or []: + if not isinstance(cfg, dict) or cfg.get("agent_id") is None: + continue + try: + aid = int(cfg["agent_id"]) + except (TypeError, ValueError): + continue + row = seeded.get(aid, {}) + world.add_actor(ActorState( + aid, row.get("name") or cfg.get("entity_name") or f"Agent_{aid}", + agency_class=row.get("agency_class") or cfg.get("agency_class") or "other_actor", + archetype=row.get("archetype") or cfg.get("archetype") or "actor", + capacity_per_round=row.get("capacity_per_round") or DEFAULT_CAPACITY_PER_ROUND, + outcome_power=row.get("outcome_power", cfg.get("outcome_power")), + posture=row.get("posture") or cfg.get("stance") or "", + documented_plans=row.get("documented_plans"), red_lines=row.get("red_lines"), + aliases=row.get("aliases"), goals=row.get("goals"), + incentives=row.get("incentives") or { + k: v for k, v in (("gains_if", cfg.get("gains_if")), ("loses_if", cfg.get("loses_if"))) if v + }, + )) + for aid, row in seeded.items(): + if aid not in world.actors: + world.add_actor(ActorState( + aid, row.get("name") or f"Agent_{aid}", agency_class=row.get("agency_class") or "other_actor", + archetype=row.get("archetype") or "actor", + capacity_per_round=row.get("capacity_per_round") or DEFAULT_CAPACITY_PER_ROUND, + outcome_power=row.get("outcome_power"), posture=row.get("posture") or "", + documented_plans=row.get("documented_plans"), red_lines=row.get("red_lines"), + aliases=row.get("aliases"), goals=row.get("goals"), incentives=row.get("incentives"))) + for row in seed.get("state_variables") or []: + if not isinstance(row, dict): + continue + name = row.get("name") or row.get("metric") + if not name: + continue + world.add_variable(name, row.get("value"), unit=row.get("unit") or "", + kind=row.get("kind") or "numeric", as_of=row.get("as_of") or row.get("as_of_date"), + provenance=row.get("provenance") or "research", + rationale=row.get("rationale") or row.get("source") or "", + var_id=row.get("id")) + for row in seed.get("relationships") or []: + if not isinstance(row, dict): + continue + s = world.resolve_actor(row.get("source_agent_id", row.get("source"))) + t = world.resolve_actor(row.get("target_agent_id", row.get("target"))) + if s is None or t is None or s == t: + continue + world.add_relationship(Relationship( + s, t, kind=row.get("kind") or row.get("type") or "RELATES_TO", + valence=row.get("valence") or "neutral", polarity=row.get("polarity"), + strength=row.get("strength") if isinstance(row.get("strength"), (int, float)) else None, + basis=row.get("basis") or "")) + return world + + def add_actor(self, actor: ActorState) -> None: + self.actors[actor.agent_id] = actor + for key in actor.name_keys(): + self._name_index.setdefault(key, actor.agent_id) + + def add_variable(self, name: Any, value: Any, *, unit: str = "", kind: str = "numeric", + as_of: Optional[str] = None, provenance: str = "research", rationale: str = "", + var_id: Optional[str] = None, initial_round: int = 0, + initial_cause: str = "") -> str: + """Register (or update) a variable; returns its id. Duplicate names merge.""" + vid = slugify(var_id or name) + if vid in self.variables: + self.variables[vid].set_value(value, round_num=self.round, source=provenance, + cause="re-seeded") + return vid + if kind == "numeric": + num, parsed_unit = parse_quantity(value) + if num is None: + kind = "text" + elif not unit and parsed_unit: + unit = parsed_unit + self.variables[vid] = StateVariable(vid, name, value, unit=unit, kind=kind, as_of=as_of, + provenance=provenance, rationale=rationale) + self.variables[vid].history = [{ + "round": int(initial_round), + "value": self.variables[vid].value, + "cause": _text(initial_cause, 200) or ("seed" if int(initial_round) == 0 else provenance), + "source": provenance, + }] + return vid + + def add_relationship(self, rel: Relationship) -> None: + self.relationships[rel.key] = rel + + def resolve_actor(self, ref: Any) -> Optional[int]: + """Resolve an agent id or a (possibly aliased, partial) actor name.""" + if ref is None or isinstance(ref, bool): + return None + if isinstance(ref, int) and ref in self.actors: + return ref + if isinstance(ref, float) and ref.is_integer() and int(ref) in self.actors: + return int(ref) + text = str(ref).strip() + if not text: + return None + if text.isdigit() and int(text) in self.actors: + return int(text) + key = normalize_label(text) + if key in self._name_index: + return self._name_index[key] + # containment either way (e.g. "Alpha" vs "Alpha Systems"), longest match wins + best: Optional[Tuple[int, int]] = None + for name_key, aid in self._name_index.items(): + if len(name_key) < 3: + continue + if name_key in key or key in name_key: + score = len(name_key) + if best is None or score > best[0]: + best = (score, aid) + return best[1] if best else None + + def actor_name(self, agent_id: Any) -> str: + a = self.actors.get(agent_id) if isinstance(agent_id, int) else None + return a.name if a else str(agent_id) + + def relationships_of(self, agent_id: int) -> List[Relationship]: + return [r for r in self.relationships.values() if r.involves(agent_id)] + + def _relationship_between(self, a: int, b: int) -> Optional[Relationship]: + return self.relationships.get((a, b)) or self.relationships.get((b, a)) + + def _ensure_relationship(self, a: int, b: int, *, kind: str, round_num: int, basis: str) -> Relationship: + rel = self._relationship_between(a, b) + if rel is None: + rel = Relationship(a, b, kind=kind, valence="neutral", polarity=0.0, strength=0.3, + basis=basis, last_changed_round=round_num) + self.add_relationship(rel) + return rel + + def _next_effect_id(self, round_num: int) -> str: + self._effect_seq += 1 + return f"fx{round_num}_{self._effect_seq}" + + def _add_event(self, event: WorldEvent) -> WorldEvent: + self.events.append(event) + if len(self.events) > EVENT_LIMIT: + self.events = self.events[-EVENT_LIMIT:] + return event + + # -- round mechanics ----------------------------------------------------------- + def set_period_labels(self, labels: Dict[Any, Any]) -> None: + """Known round → period label map (1-based rounds) for due-date rendering.""" + for k, v in (labels or {}).items(): + try: + self.period_labels[int(k)] = _text(v, 40) + except (TypeError, ValueError): + continue + + def start_round(self, round_num: int, period: Optional[Dict[str, Any]]) -> None: + """Open round ``round_num`` (1-based): reset capacity, mark due commitments.""" + self.round = int(round_num) + self.period = dict(period) if isinstance(period, dict) else None + if self.period and self.period.get("label"): + self.period_labels[self.round] = _text(self.period["label"], 40) + for actor in self.actors.values(): + actor.capacity_used = 0 + for c in actor.commitments: + if c.get("status") == "pending" and int(c.get("due_round", 0)) <= self.round: + c["status"] = "due" + + def register_scheduled_events(self, round_num: int, fired_events: Iterable[Dict[str, Any]]) -> List[WorldEvent]: + out: List[WorldEvent] = [] + seen = set() + label = str((self.period or {}).get("label") or "") + for ev in fired_events or []: + if not isinstance(ev, dict): + continue + content = _text(ev.get("content") or ev.get("event") or ev.get("description"), 320) + if not content: + continue + key = (str(ev.get("date") or ""), content) + if key in seen: + continue + seen.add(key) + actors = [] + poster = ev.get("poster_name") + if poster: + actors.append(str(poster)) + out.append(self._add_event(WorldEvent(round_num, "scheduled", content, actors=actors, + source="research_timeline", period_label=label))) + return out + + def register_decisions(self, round_num: int, decisions: Iterable[Dict[str, Any]]) -> List[WorldEvent]: + """Apply the deterministic part of the actors' structured decisions. + + Each decision row (``agent_decision.AgentDecision.to_dict``) carries + ``agent_id``, ``option`` (id/label/kind/cost/lag/reversibility), ``targets`` + (agent ids), ``magnitude``, ``public_statement``, ``commitment``, + ``alliance_moves`` and ``status``. Capacity is consumed, commitments + registered, coalition/antagonistic moves nudge relationships, and an + ``action`` event is recorded. Decisions whose status is not ``ok`` only + record a ``hold`` event. + """ + out: List[WorldEvent] = [] + label = str((self.period or {}).get("label") or "") + for d in decisions or []: + if not isinstance(d, dict): + continue + aid = self.resolve_actor(d.get("agent_id")) + if aid is None: + continue + actor = self.actors[aid] + option = d.get("option") if isinstance(d.get("option"), dict) else {} + kind = _text(option.get("kind"), 32) or "communication" + option_id = _text(option.get("id"), 64) or "hold_position" + try: + magnitude = _clamp(float(d.get("magnitude", 0.5) or 0.0), 0.0, 1.0) + except (TypeError, ValueError): + magnitude = 0.5 + targets = [t for t in (self.resolve_actor(x) for x in (d.get("targets") or [])) if t is not None and t != aid] + target_names = [self.actor_name(t) for t in targets] + status = _text(d.get("status"), 24) or "ok" + try: + cost = max(0, int(option.get("cost", 1) or 0)) + except (TypeError, ValueError): + cost = 1 + try: + lag = max(0, int(option.get("lag", 0) or 0)) + except (TypeError, ValueError): + lag = 0 + actor.capacity_used = min(actor.capacity_per_round, actor.capacity_used + cost) + description = _text(option.get("label"), 160) or option_id + if target_names: + description += " → " + ", ".join(target_names[:4]) + actor.last_action = { + "round": int(round_num), "option_id": option_id, "kind": kind, + "label": _text(option.get("label"), 160), "targets": target_names, + "magnitude": round(magnitude, 3), "status": status, "resolution": None, + "reversibility": _text(option.get("reversibility"), 24), + } + event_kind = "action" + if option_id == "hold_position" or status != "ok": + description = (f"{actor.name} holds position" if status == "ok" + else f"{actor.name} makes no recorded move ({status})") + out.append(self._add_event(WorldEvent( + round_num, event_kind, description, actors=[actor.name], targets=target_names, + source="decision", statement=d.get("public_statement") or "", + option_id=option_id, action_kind=kind, period_label=label))) + if status != "ok": + continue + if option_id.startswith(RESPONSE_PREFIXES): + # OFFERS: the recipient's answer settles the offer; nothing else in this decision + # can create an agreement (consent rule) + verb, _, iid = option_id.partition(":") + out.extend(self.resolve_interaction(round_num, iid, verb, aid, + statement=d.get("public_statement") or "", + terms=d.get("terms") or "", magnitude=magnitude, lag=lag)) + continue + if option.get("reversibility") == "irreversible" and option_id != "hold_position": + if option_id not in actor.irreversible_option_ids: + actor.irreversible_option_ids.append(option_id) + if option_id != "hold_position": + # a commitment that fell due this period is honoured by acting on it; a holder + # lets it lapse (see lapse_overdue_commitments) — mechanical, no LLM judgement + for c in actor.commitments: + if c.get("status") == "due": + c["status"] = "fulfilled" + c["fulfilled_round"] = int(round_num) + commitment = d.get("commitment") if isinstance(d.get("commitment"), dict) else None + if commitment and _text(commitment.get("description"), 220): + try: + due_periods = max(0, int(commitment.get("due_periods", lag) or 0)) + except (TypeError, ValueError): + due_periods = lag + actor.add_commitment(commitment["description"], made_round=round_num, + due_round=round_num + max(1, due_periods)) + elif lag > 0 and option_id != "hold_position" and option_id not in PROPOSAL_OPTION_IDS: + # (a proposing move's pending item is the offer itself, not an announced commitment) + actor.add_commitment(f"{_text(option.get('label'), 120) or option_id} (announced)", + made_round=round_num, due_round=round_num + lag, source="option_lag") + # relationship dynamics: coalition moves pull closer, antagonistic ones push apart + for move in d.get("alliance_moves") or []: + if not isinstance(move, dict): + continue + other = self.resolve_actor(move.get("target")) + if other is None or other == aid: + continue + verb = _text(move.get("move"), 24).casefold() + delta = {"propose": 0.2, "strengthen": 0.15, "join": 0.2, "support": 0.15, + "break": -0.35, "oppose": -0.25, "sanction": -0.3, "distance": -0.15}.get(verb) + if delta is None: + continue + rel = self._ensure_relationship(aid, other, kind="ALLY_OF" if delta > 0 else "OPPOSES", + round_num=round_num, basis=f"{verb} move in round {round_num}") + rel.nudge(delta * (0.5 + 0.5 * magnitude), round_num=round_num, + cause=f"{actor.name} {verb} {self.actor_name(other)}") + if kind in ("coalition",) and targets: + for other in targets: + rel = self._ensure_relationship(aid, other, kind="PARTNERS_WITH", round_num=round_num, + basis=description) + rel.nudge(0.12 * (0.5 + 0.5 * magnitude), round_num=round_num, cause=description) + elif kind in ("legal", "coercive") and targets: + for other in targets: + rel = self._ensure_relationship(aid, other, kind="OPPOSES", round_num=round_num, + basis=description) + rel.nudge(-0.15 * (0.5 + 0.5 * magnitude), round_num=round_num, cause=description) + # OFFERS: a proposing move aimed at another actor opens an offer the recipient must + # answer; withdrawing from an agreement in force is an explicit breach + if option_id in PROPOSAL_OPTION_IDS and targets: + terms = (_text(d.get("terms"), INTERACTION_TERMS_MAX_CHARS) + or _text(d.get("public_statement"), INTERACTION_TERMS_MAX_CHARS) or description) + for other in targets[:2]: + out.append(self.open_interaction(round_num, aid, other, option_id, terms)) + elif option_id == "break_alliance" and targets: + for other in targets[:2]: + out.extend(self.breach_agreements_between(round_num, aid, other, + cause=f"{actor.name} withdrew: {description}")) + return out + + # -- offers and agreements (consent rule) ---------------------------------------- + def open_interaction(self, round_num: int, proposer: int, recipient: int, option_id: str, terms: str, *, + kind: str = "proposal", parent_id: str = "") -> WorldEvent: + """Record an offer from ``proposer`` to ``recipient``; returns its ``offer`` event.""" + self._interaction_seq += 1 + iid = f"i{int(round_num)}_{self._interaction_seq}" + inter = Interaction(iid, proposer=proposer, recipient=recipient, option_id=option_id, terms=terms, + opened_round=round_num, expires_round=int(round_num) + INTERACTION_TTL_PERIODS, + kind=kind, parent_id=parent_id) + self.interactions[iid] = inter + if len(self.interactions) > INTERACTION_LIMIT: + for old_id in [i for i, it in self.interactions.items() if not it.is_open()]: + del self.interactions[old_id] + if len(self.interactions) <= INTERACTION_LIMIT: + break + pname, rname = self.actor_name(proposer), self.actor_name(recipient) + what = "counter-offer" if kind == "counter" else "offer" + return self._add_event(WorldEvent( + round_num, "offer", + f"{pname} made an {what} to {rname} [{iid}]: {inter.terms} " + f"(answer due by {self._period_label_for_round(inter.expires_round)})", + actors=[pname], targets=[rname], source="interaction", option_id=option_id, + period_label=str((self.period or {}).get("label") or ""))) + + def open_interactions_for(self, agent_id: int) -> List[Interaction]: + """Offers the actor still has to answer, oldest first.""" + return sorted((it for it in self.interactions.values() if it.recipient == int(agent_id) and it.is_open()), + key=lambda it: (it.opened_round, it.id)) + + def agreements_between(self, a: int, b: int) -> List[Interaction]: + return [it for it in self.interactions.values() if it.status == "accepted" + and {it.proposer, it.recipient} == {int(a), int(b)}] + + def resolve_interaction(self, round_num: int, iid: str, verb: str, actor_id: int, *, statement: str = "", + terms: str = "", magnitude: float = 0.5, lag: int = 0) -> List[WorldEvent]: + """The recipient's answer: accept (binds both parties), reject, counter or defer.""" + inter = self.interactions.get(str(iid)) + name = self.actor_name(actor_id) + if inter is None or inter.recipient != int(actor_id) or not inter.is_open(): + return [self._add_event(WorldEvent(round_num, "system", + f"{name} answered an offer that is no longer open ({iid})", + actors=[name], source="interaction"))] + pname, rname = self.actor_name(inter.proposer), self.actor_name(inter.recipient) + inter.response = _text(statement, 200) + inter.resolved_round = int(round_num) + label = str((self.period or {}).get("label") or "") + events: List[WorldEvent] = [] + if verb == "accept": + inter.status = "accepted" + due = int(round_num) + max(1, int(lag or 0)) + for party, other in ((inter.proposer, inter.recipient), (inter.recipient, inter.proposer)): + row = self.actors[party].add_commitment( + f"Honour the agreement with {self.actor_name(other)}: {inter.terms}", + made_round=round_num, due_round=due, source=f"agreement:{inter.id}") + row["counterparty"] = int(other) + inter.obligations.append(row["id"]) + rel = self._ensure_relationship(inter.proposer, inter.recipient, kind="PARTNERS_WITH", + round_num=round_num, basis=f"agreement {inter.id}") + rel.nudge(0.25 * (0.5 + 0.5 * magnitude), round_num=round_num, + cause=f"{rname} accepted {pname}'s offer") + events.append(self._add_event(WorldEvent( + round_num, "agreement", + f"Agreement [{inter.id}]: {rname} accepted {pname}'s offer — {inter.terms} " + f"(both sides' obligations due {self._period_label_for_round(due)})", + actors=[rname, pname], targets=[pname], source="interaction", statement=statement, + option_id=inter.option_id, period_label=label))) + elif verb == "reject": + inter.status = "rejected" + rel = self._ensure_relationship(inter.proposer, inter.recipient, kind="RELATES_TO", + round_num=round_num, basis=f"offer {inter.id} rejected") + rel.nudge(-0.1 * (0.5 + 0.5 * magnitude), round_num=round_num, + cause=f"{rname} rejected {pname}'s offer") + events.append(self._add_event(WorldEvent( + round_num, "offer", f"{rname} rejected {pname}'s offer [{inter.id}]: {inter.terms}", + actors=[rname], targets=[pname], source="interaction", statement=statement, + option_id=inter.option_id, period_label=label))) + elif verb == "counter": + inter.status = "countered" + new_terms = (_text(terms, INTERACTION_TERMS_MAX_CHARS) + or _text(statement, INTERACTION_TERMS_MAX_CHARS) or inter.terms) + events.append(self._add_event(WorldEvent( + round_num, "offer", f"{rname} countered {pname}'s offer [{inter.id}]", + actors=[rname], targets=[pname], source="interaction", statement=statement, + option_id=inter.option_id, period_label=label))) + events.append(self.open_interaction(round_num, inter.recipient, inter.proposer, inter.option_id, + new_terms, kind="counter", parent_id=inter.id)) + else: # defer: the deadline moves one period, at most twice — then the offer lapses + inter.deferrals += 1 + if inter.deferrals > INTERACTION_MAX_DEFERRALS: + inter.status = "lapsed" + events.append(self._add_event(WorldEvent( + round_num, "offer", + f"{pname}'s offer to {rname} [{inter.id}] lapsed after repeated deferral: {inter.terms}", + actors=[rname], targets=[pname], source="interaction", statement=statement, + option_id=inter.option_id, period_label=label))) + else: + inter.status = "deferred" + inter.expires_round = max(inter.expires_round, int(round_num) + 1) + events.append(self._add_event(WorldEvent( + round_num, "offer", + f"{rname} deferred its answer to {pname}'s offer [{inter.id}] " + f"(open until {self._period_label_for_round(inter.expires_round)})", + actors=[rname], targets=[pname], source="interaction", statement=statement, + option_id=inter.option_id, period_label=label))) + return events + + def lapse_expired_interactions(self, round_num: int) -> List[Dict[str, Any]]: + """Offers nobody answered in time lapse (visible to everyone).""" + lapsed: List[Dict[str, Any]] = [] + for inter in self.interactions.values(): + if inter.is_open() and inter.expires_round < int(round_num): + inter.status = "lapsed" + inter.resolved_round = int(round_num) + pname, rname = self.actor_name(inter.proposer), self.actor_name(inter.recipient) + self._add_event(WorldEvent(round_num, "offer", + f"{pname}'s offer to {rname} [{inter.id}] lapsed unanswered: {inter.terms}", + actors=[pname], targets=[rname], source="interaction", + option_id=inter.option_id, + period_label=str((self.period or {}).get("label") or ""))) + lapsed.append(inter.to_dict()) + return lapsed + + def breach_agreements_between(self, round_num: int, breacher: int, other: int, *, cause: str) -> List[WorldEvent]: + """An explicit withdrawal from every agreement in force between two actors.""" + events: List[WorldEvent] = [] + for inter in self.agreements_between(breacher, other): + events.append(self._breach(round_num, inter, breacher, cause)) + return events + + def _breach(self, round_num: int, inter: Interaction, breacher: int, cause: str) -> WorldEvent: + inter.status = "breached" + inter.resolved_round = int(round_num) + other = inter.recipient if inter.proposer == int(breacher) else inter.proposer + for party in (inter.proposer, inter.recipient): + actor = self.actors.get(party) + if actor is None: + continue + for c in actor.commitments: + if c.get("id") not in inter.obligations: + continue + if party == int(breacher) and c.get("status") in ("pending", "due", "lapsed"): + c["status"] = "breached" + elif party != int(breacher) and c.get("status") in ("pending", "due"): + c["status"] = "released" + bname, oname = self.actor_name(breacher), self.actor_name(other) + actor = self.actors.get(int(breacher)) + if actor is not None: + actor.reputation = _clamp(actor.reputation - 0.15, 0.0, 1.0) + rel = self._ensure_relationship(int(breacher), other, kind="OPPOSES", round_num=round_num, + basis=f"breach of {inter.id}") + rel.nudge(-0.3, round_num=round_num, cause=f"{bname} breached the agreement with {oname}") + return self._add_event(WorldEvent( + round_num, "breach", f"Breach [{inter.id}]: {bname} broke its agreement with {oname} — {cause}", + actors=[bname], targets=[oname], source="interaction", option_id=inter.option_id, + period_label=str((self.period or {}).get("label") or ""))) + + def apply_arbiter_update(self, round_num: int, update: Dict[str, Any]) -> Dict[str, Any]: + """Apply a *validated* arbiter update (see ``world_update.validate_arbiter_update``). + + Returns a summary ``{variables_changed, variables_added, events, relationships_changed, + effects_added, resolutions}`` for the round record. + """ + summary = {"variables_changed": [], "variables_added": [], "events": 0, + "relationships_changed": [], "effects_added": 0, "resolutions": 0} + if not isinstance(update, dict): + return summary + ratio = self.settings.get("max_change_ratio", DEFAULT_MAX_CHANGE_RATIO) + try: + ratio = float(ratio) if ratio is not None else None + except (TypeError, ValueError): + ratio = DEFAULT_MAX_CHANGE_RATIO + label = str((self.period or {}).get("label") or "") + for ch in update.get("variable_changes") or []: + if not isinstance(ch, dict): + continue + var = self.variables.get(str(ch.get("variable_id") or "")) + if var is None: + continue + before = var.value + if ch.get("new_value") is not None: + target = ch["new_value"] + elif ch.get("delta") is not None and var.kind == "numeric" and isinstance(var.value, (int, float)): + try: + target = float(var.value) + float(ch["delta"]) + except (TypeError, ValueError): + continue + else: + continue + if var.set_value(target, round_num=round_num, source="arbiter", + cause=ch.get("rationale") or "arbiter", max_change_ratio=ratio): + summary["variables_changed"].append({"id": var.id, "name": var.name, "from": before, + "to": var.value, "unit": var.unit}) + for nv in update.get("new_variables") or []: + if not isinstance(nv, dict) or not nv.get("name"): + continue + vid = slugify(nv.get("id") or nv.get("name")) + if vid in self.variables: + continue + self.add_variable(nv["name"], nv.get("value"), unit=nv.get("unit") or "", + kind=nv.get("kind") or "numeric", as_of=str((self.period or {}).get("period_end") or ""), + provenance="arbiter", rationale=nv.get("rationale") or "", var_id=vid, + initial_round=round_num, initial_cause=nv.get("rationale") or "") + summary["variables_added"].append({"id": vid, "name": self.variables[vid].name, + "value": self.variables[vid].value, + "unit": self.variables[vid].unit}) + for ev in update.get("events") or []: + if not isinstance(ev, dict) or not _text(ev.get("description"), 320): + continue + kind = ev.get("kind") if ev.get("kind") in ("emergent", "consequence", "resolution") else "emergent" + self._add_event(WorldEvent(round_num, kind, ev["description"], actors=ev.get("actors"), + targets=ev.get("targets"), source="arbiter", + variable_ids=ev.get("variable_ids"), period_label=label)) + summary["events"] += 1 + for rc in update.get("relationship_changes") or []: + if not isinstance(rc, dict): + continue + a = self.resolve_actor(rc.get("source")) + b = self.resolve_actor(rc.get("target")) + if a is None or b is None or a == b: + continue + direction = _text(rc.get("direction"), 16).casefold() + delta = {"closer": 0.15, "farther": -0.15, "further": -0.15, "break": -0.35, "ally": 0.25}.get(direction) + if delta is None: + continue + rel = self._ensure_relationship(a, b, kind="ALLY_OF" if delta > 0 else "OPPOSES", + round_num=round_num, basis=rc.get("rationale") or "arbiter") + moved = rel.nudge(delta, round_num=round_num, cause=rc.get("rationale") or "arbiter") + summary["relationships_changed"].append({"source": self.actor_name(a), "target": self.actor_name(b), + "valence": rel.valence, "delta": round(moved, 4)}) + for fx in update.get("pending_effects") or []: + if not isinstance(fx, dict) or not _text(fx.get("description"), 220): + continue + try: + due = round_num + max(1, int(fx.get("due_periods", 1) or 1)) + except (TypeError, ValueError): + due = round_num + 1 + vid = fx.get("variable_id") + if vid is not None and str(vid) not in self.variables: + vid = None + op = "add" if fx.get("delta") is not None and fx.get("new_value") is None else "set" + value = fx.get("delta") if op == "add" else fx.get("new_value") + self.pending_effects.append(PendingEffect( + self._next_effect_id(round_num), due_round=due, description=fx["description"], + variable_id=str(vid) if vid else None, op=op, value=value, + probability=fx.get("probability", 1.0), source="arbiter", + targets=fx.get("targets"), created_round=round_num)) + summary["effects_added"] += 1 + for res in update.get("action_resolutions") or []: + if not isinstance(res, dict): + continue + aid = self.resolve_actor(res.get("actor") if res.get("actor") is not None else res.get("agent_id")) + if aid is None: + continue + actor = self.actors[aid] + outcome = _text(res.get("outcome"), 16).casefold() or "pending" + if actor.last_action and actor.last_action.get("round") == round_num: + actor.last_action["resolution"] = outcome + actor.last_action["resolution_rationale"] = _text(res.get("rationale"), 200) + if outcome in ("success", "fulfilled"): + actor.reputation = _clamp(actor.reputation + 0.02, 0.0, 1.0) + elif outcome in ("blocked", "failed"): + actor.reputation = _clamp(actor.reputation - 0.05, 0.0, 1.0) + summary["resolutions"] += 1 + return summary + + def fire_due_effects(self, round_num: int, rng: Any) -> List[Dict[str, Any]]: + """Resolve pending effects whose round has come (probability via ``rng``).""" + fired: List[Dict[str, Any]] = [] + keep: List[PendingEffect] = [] + label = str((self.period or {}).get("label") or "") + for fx in self.pending_effects: + if fx.due_round > round_num: + keep.append(fx) + continue + roll = 0.0 + if fx.probability < 1.0: + try: + roll = float(rng.random()) + except Exception: # noqa: BLE001 + roll = 0.0 + happened = roll < fx.probability + desc = fx.description if happened else f"Did not materialise: {fx.description}" + var_ids: List[str] = [] + if happened and fx.variable_id and fx.variable_id in self.variables: + var = self.variables[fx.variable_id] + if fx.op == "set": + target = fx.value + elif var.kind == "numeric" and isinstance(var.value, (int, float)): + try: + target = (float(var.value) + float(fx.value) if fx.op == "add" + else float(var.value) * float(fx.value)) + except (TypeError, ValueError): + target = None + else: + target = None + if target is not None and var.set_value(target, round_num=round_num, source="effect", + cause=fx.description): + var_ids.append(var.id) + self._add_event(WorldEvent(round_num, "consequence", desc, targets=fx.targets, + source=fx.source or "effect", variable_ids=var_ids, + period_label=label)) + fired.append({"id": fx.id, "description": desc, "happened": happened, + "variable_ids": var_ids}) + self.pending_effects = keep + return fired + + def lapse_overdue_commitments(self, round_num: int) -> List[Dict[str, Any]]: + """Commitments due before this round that were never resolved lapse (visible).""" + lapsed: List[Dict[str, Any]] = [] + for actor in list(self.actors.values()): + for c in actor.commitments: + if c.get("status") == "due" and int(c.get("due_round", 0)) < round_num: + c["status"] = "lapsed" + lapsed.append({"actor": actor.name, **c}) + self._add_event(WorldEvent(round_num, "consequence", + f"{actor.name}'s commitment lapsed: {c.get('description')}", + actors=[actor.name], source="commitment")) + source = str(c.get("source") or "") + if source.startswith("agreement:"): + # OFFERS: an obligation under an agreement that lapses is a breach + inter = self.interactions.get(source.split(":", 1)[1]) + if inter is not None and inter.status == "accepted": + self._breach(round_num, inter, actor.agent_id, + cause=f"its obligation lapsed: {c.get('description')}") + return lapsed + + def commit_round(self, round_num: int) -> None: + self.committed_round = int(round_num) + + # -- observation --------------------------------------------------------------- + def snapshot(self) -> Dict[str, Any]: + """Compact, comparable view of the world (used for diffs and the record).""" + return { + "round": self.round, + "period": dict(self.period) if self.period else None, + "variables": {vid: {"name": v.name, "value": v.value, "unit": v.unit, "kind": v.kind} + for vid, v in self.variables.items()}, + "relationships": {f"{r.source}->{r.target}": {"valence": r.valence, + "polarity": round(r.polarity, 4)} + for r in self.relationships.values()}, + "pending_effects": len(self.pending_effects), + "commitments_pending": sum(len(a.pending_commitments()) for a in self.actors.values()), + "interactions": {iid: it.status for iid, it in self.interactions.items()}, + } + + def diff(self, previous: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Changes since ``previous`` (a ``snapshot()``).""" + prev_vars = (previous or {}).get("variables") or {} + prev_rels = (previous or {}).get("relationships") or {} + changed: List[Dict[str, Any]] = [] + added: List[Dict[str, Any]] = [] + for vid, v in self.variables.items(): + before = prev_vars.get(vid) + if before is None: + if previous is not None: + added.append({"id": vid, "name": v.name, "value": v.value, "unit": v.unit}) + continue + if before.get("value") != v.value: + changed.append({"id": vid, "name": v.name, "from": before.get("value"), + "to": v.value, "unit": v.unit}) + rel_changes: List[Dict[str, Any]] = [] + for r in self.relationships.values(): + key = f"{r.source}->{r.target}" + before = prev_rels.get(key) + if before is None: + if previous is not None: + rel_changes.append({"source": self.actor_name(r.source), "target": self.actor_name(r.target), + "from": None, "to": r.valence, "polarity": round(r.polarity, 4)}) + continue + if abs(float(before.get("polarity", 0.0)) - r.polarity) > 1e-9 or before.get("valence") != r.valence: + rel_changes.append({"source": self.actor_name(r.source), "target": self.actor_name(r.target), + "from": before.get("valence"), "to": r.valence, + "polarity": round(r.polarity, 4), + "direction": "closer" if r.polarity > float(before.get("polarity", 0.0)) else "farther"}) + prev_inter = (previous or {}).get("interactions") or {} + inter_changes: List[Dict[str, Any]] = [] + for iid, it in self.interactions.items(): + before = prev_inter.get(iid) + if before == it.status or (before is None and previous is None): + continue + inter_changes.append({"id": iid, "from": before, "to": it.status, "kind": it.kind, + "proposer": self.actor_name(it.proposer), "recipient": self.actor_name(it.recipient), + "terms": it.terms}) + return {"variables_changed": changed, "variables_added": added, "relationships_changed": rel_changes, + "interactions_changed": inter_changes} + + def events_in_round(self, round_num: int, kinds: Optional[Iterable[str]] = None) -> List[WorldEvent]: + ks = set(kinds) if kinds else None + return [e for e in self.events if e.round == round_num and (ks is None or e.kind in ks)] + + def _period_label_for_round(self, round_num: int) -> str: + label = self.period_labels.get(int(round_num)) + if label: + return label + if self.period and self.round == round_num and self.period.get("label"): + return str(self.period["label"]) + return f"period {round_num}" + + def render_public_brief(self, *, upto_round: Optional[int] = None, + previous: Optional[Dict[str, Any]] = None, + max_chars: int = PUBLIC_BRIEF_MAX_CHARS) -> str: + """Agent-facing WORLD STATE text (shared by all actors; herding guard I-1). + + ``upto_round`` is the last committed round whose moves/consequences are + reported; ``previous`` is the snapshot taken before that round so deltas + can be shown. Scenario shares are never rendered. + """ + r = int(upto_round if upto_round is not None else self.committed_round) + as_of = str((self.period or {}).get("period_end") or self.as_of_date or "") + label = str((self.period or {}).get("label") or "") + head = f"# WORLD STATE — as of {as_of}" if as_of else "# WORLD STATE" + if label and r > 0: + head += f" (after {label})" + lines: List[str] = [head] + diff = self.diff(previous) if previous is not None else {"variables_changed": [], "variables_added": [], + "relationships_changed": []} + changed_ids = {c["id"]: c for c in diff["variables_changed"]} + if self.variables: + lines.append("## Key facts") + ordered = sorted(self.variables.values(), + key=lambda v: (0 if v.id in changed_ids else 1, + -(v.history[-1].get("round", 0) if v.history else 0), v.name)) + for v in ordered[:14]: + line = f"- {v.name}: {format_value(v.value, v.unit)}" + ch = changed_ids.get(v.id) + if ch is not None: + line += f" (was {format_value(ch['from'], v.unit)} last period)" + elif v.as_of and v.provenance in ("research", "official"): + line += f" (as of {v.as_of})" + lines.append(line) + moves = self.events_in_round(r, ("action",)) if r > 0 else [] + if moves: + lines.append("## Moves last period") + for e in moves[:10]: + who = e.actors[0] if e.actors else "An actor" + stmt = f' — "{_text(e.statement, 160)}"' if e.statement else "" + lines.append(f"- {who}: {e.description}{stmt}") + consequences = self.events_in_round(r, ("scheduled", "consequence", "emergent", "resolution")) if r > 0 else [] + if consequences: + lines.append("## Consequences and events") + for e in consequences[:10]: + lines.append(f"- {e.description}") + rels = diff["relationships_changed"] + if rels: + lines.append("## Alliances and rivalries") + for rc in rels[:8]: + word = {"allied": "allied", "adversarial": "adversarial", "neutral": "neutral", + "transactional": "transactional", "directional": "linked"}.get(rc.get("to"), rc.get("to")) + direction = rc.get("direction") + suffix = f" ({direction})" if direction else "" + lines.append(f"- {rc['source']} — {rc['target']}: now {word}{suffix}") + deals = self.events_in_round(r, ("offer", "agreement", "breach")) if r > 0 else [] + open_offers = [it for it in self.interactions.values() if it.is_open()] + if deals or open_offers: + lines.append("## Offers and agreements") + for e in deals[:8]: + lines.append(f"- {e.description}") + for it in open_offers[:6]: + if not any(f"[{it.id}]" in e.description for e in deals): + lines.append(f"- Open: {self.actor_name(it.proposer)} → {self.actor_name(it.recipient)} [{it.id}]: " + f"{it.terms} (answer due by {self._period_label_for_round(it.expires_round)})") + pending: List[str] = [] + for actor in self.actors.values(): + for c in actor.pending_commitments(): + pending.append(f"- {actor.name}: {c.get('description')} (due {self._period_label_for_round(int(c.get('due_round', 0)))})") + for fx in self.pending_effects: + if fx.description: + pending.append(f"- Expected: {fx.description} (around {self._period_label_for_round(fx.due_round)})") + if pending: + lines.append("## Pending and announced") + lines.extend(pending[:8]) + return _bound_lines(lines, max_chars) + + def render_private_brief(self, agent_id: int, *, upto_round: Optional[int] = None, + max_chars: int = PRIVATE_BRIEF_MAX_CHARS) -> str: + """Actor-private situation note (its own commitments, consequences aimed at it).""" + actor = self.actors.get(agent_id) + if actor is None: + return "" + r = int(upto_round if upto_round is not None else self.committed_round) + lines = [f"# YOUR SITUATION — {actor.name}", + f"- Capacity this period: {actor.capacity_remaining}/{actor.capacity_per_round} action units"] + if actor.last_action: + la = actor.last_action + res = la.get("resolution") + tail = f"; outcome so far: {res}" if res else "" + lines.append(f"- Your last move ({self._period_label_for_round(int(la.get('round', 0)))}): " + f"{la.get('label') or la.get('option_id')}{tail}") + pend = actor.pending_commitments() + if pend: + lines.append("- Your open commitments:") + for c in pend[:5]: + flag = " (DUE NOW)" if c.get("status") == "due" else "" + lines.append(f" · {c.get('description')} — due {self._period_label_for_round(int(c.get('due_round', 0)))}{flag}") + offers = self.open_interactions_for(agent_id) + if offers: + lines.append("- Offers awaiting YOUR answer (accept, reject, counter or defer this period; unanswered offers lapse):") + for it in offers[:3]: + lines.append(f" · [{it.id}] from {self.actor_name(it.proposer)}: {it.terms} " + f"(open until {self._period_label_for_round(it.expires_round)})") + own = [it for it in self.interactions.values() if it.proposer == agent_id and it.is_open()] + if own: + lines.append("- Your offers awaiting an answer: " + "; ".join( + f"to {self.actor_name(it.recipient)} [{it.id}]: {_text(it.terms, 80)}" for it in own[:3])) + aimed = [e for e in self.events_in_round(r) if actor.name in e.targets] if r > 0 else [] + if aimed: + lines.append("- Aimed at you last period:") + for e in aimed[:5]: + who = e.actors[0] if e.actors else "someone" + lines.append(f" · {who}: {e.description}") + allies, rivals, changed = [], [], [] + for rel in self.relationships_of(agent_id): + other = rel.target if rel.source == agent_id else rel.source + name = self.actor_name(other) + if rel.valence == "allied": + allies.append(name) + elif rel.valence == "adversarial": + rivals.append(name) + if rel.last_changed_round == r and r > 0: + changed.append(f"{name} ({rel.valence})") + if allies: + lines.append("- Allies / partners: " + ", ".join(sorted(set(allies))[:8])) + if rivals: + lines.append("- Rivals / opponents: " + ", ".join(sorted(set(rivals))[:8])) + if changed: + lines.append("- Relationships that moved last period: " + ", ".join(changed[:6])) + if actor.red_lines: + lines.append("- Your red lines: " + "; ".join(actor.red_lines[:3])) + return _bound_lines(lines, max_chars) + + # -- persistence --------------------------------------------------------------- + def to_dict(self) -> Dict[str, Any]: + return { + "schema_version": WORLD_MODEL_STATE_VERSION, + "question": self.question, + "as_of_date": self.as_of_date, + "horizon_date": self.horizon_date, + "scenarios": list(self.scenarios), + "settings": dict(self.settings), + "round": self.round, + "committed_round": self.committed_round, + "period": dict(self.period) if self.period else None, + "scenario_shares": dict(self.scenario_shares), + "period_labels": {str(k): v for k, v in self.period_labels.items()}, + "variables": [v.to_dict() for v in self.variables.values()], + "actors": [a.to_dict() for a in self.actors.values()], + "relationships": [r.to_dict() for r in self.relationships.values()], + "pending_effects": [fx.to_dict() for fx in self.pending_effects], + "events": [e.to_dict() for e in self.events[-EVENT_LIMIT:]], + "effect_seq": self._effect_seq, + "interactions": [it.to_dict() for it in self.interactions.values()], + "interaction_seq": self._interaction_seq, + "seed_gaps": list(self.seed_gaps), + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "WorldModel": + if not isinstance(d, dict): + raise ValueError("world model state must be an object") + world = cls(question=d.get("question") or "", as_of_date=d.get("as_of_date"), + horizon_date=d.get("horizon_date"), scenarios=d.get("scenarios") or (), + settings=d.get("settings") if isinstance(d.get("settings"), dict) else None) + for row in d.get("actors") or []: + if isinstance(row, dict): + world.add_actor(ActorState.from_dict(row)) + for row in d.get("variables") or []: + if isinstance(row, dict): + var = StateVariable.from_dict(row) + world.variables[var.id] = var + for row in d.get("relationships") or []: + if isinstance(row, dict): + world.add_relationship(Relationship.from_dict(row)) + world.pending_effects = [PendingEffect.from_dict(x) for x in (d.get("pending_effects") or []) if isinstance(x, dict)] + world.events = [WorldEvent.from_dict(x) for x in (d.get("events") or []) if isinstance(x, dict)] + for row in d.get("interactions") or []: + if isinstance(row, dict) and row.get("id"): + inter = Interaction.from_dict(row) + world.interactions[inter.id] = inter + world.seed_gaps = [g for g in (d.get("seed_gaps") or []) if isinstance(g, dict)][:32] + try: + world.round = int(d.get("round") or 0) + world.committed_round = int(d.get("committed_round") or 0) + world._effect_seq = int(d.get("effect_seq") or 0) + world._interaction_seq = int(d.get("interaction_seq") or 0) + except (TypeError, ValueError): + pass + world.period = dict(d["period"]) if isinstance(d.get("period"), dict) else None + shares = d.get("scenario_shares") + if isinstance(shares, dict): + world.scenario_shares = {str(k): float(v) for k, v in shares.items() + if isinstance(v, (int, float))} + if isinstance(d.get("period_labels"), dict): + world.set_period_labels(d["period_labels"]) + return world + + +def _bound_lines(lines: List[str], max_chars: int) -> str: + """Join lines and truncate on a line boundary (never mid-line).""" + out: List[str] = [] + total = 0 + for line in lines: + line = str(line) + if total + len(line) + 1 > max_chars: + if not out: + return line[: max(0, max_chars - 1)] + break + out.append(line) + total += len(line) + 1 + return "\n".join(out) diff --git a/backend/app/services/world_model_seed.py b/backend/app/services/world_model_seed.py new file mode 100644 index 00000000..919e30d5 --- /dev/null +++ b/backend/app/services/world_model_seed.py @@ -0,0 +1,653 @@ +"""PREPARE-time ``world_model`` block for the reflexive simulation (design §2.7). + +Built deterministically (no LLM) inside ``SimulationConfigGenerator.generate_config`` +before the config is sealed, from research artifacts the preparation stage already +holds: + +* ``state_variables`` — numeric/categorical facts parsed from the dossier's + ``quantitative_facts`` and the bound official-data snapshot (latest observation at + or before the research as-of date), each with unit, as-of and provenance. +* ``actors`` — one row per cast member (audience fill excluded): agency class + (dossier value → ontology/type/role inference), archetype, class capacity, + declared outcome power only, documented plans / red lines / goals (bounded, + sanitised, actor-own evidence), allies and rivals from the valenced relationships. +* ``relationships`` — valenced edges restricted to the cast. +* ``settings`` — arbiter switch, change-ratio clamp, barrier timeout. + +Canonical actor-intelligence/v1 dossiers: plans, red lines and goals come only from +the validated context pack's producer-admitted claim rows (never from the flat +compatibility fields); analyst inference is skipped. Legacy dossiers use their flat +fields. Research engine v3 dossiers (``deerflow_bridge/linear_research.py`` ACTOR +EXTRACTION TASK) carry no plan or red-line keys at all, only ``goals`` / +``constraints`` / ``stated_vs_revealed`` / ``memory``; for them the seed derives +documented plans from the actor's own stated commitments and action-phrased goals, +and red lines from constraints or statements phrased as non-negotiables — never from +hedged wording, the "revealed" half of ``stated_vs_revealed``, incentives, +vulnerabilities or worldview (analyst reading, not world fact). Pure and never +raises (a failure degrades to a smaller block). +""" + +from __future__ import annotations + +import math +import re +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from ..utils.actors import ( + entity_archetype, + extract_forecast_inputs, + extract_quantitative_rows, + extract_relationship_rows, + match_actor, + normalize_name, + relation_polarity, + relation_valence, +) +from .actor_role_prompt import sanitize_untrusted_dossier_text +from .agent_decision import default_capacity, infer_agency_class +from .official_data import data_api, resolution_instant +from .world_model import parse_quantity, slugify + +WORLD_MODEL_SEED_VERSION = "world-model-seed/v1" +DEFAULT_MAX_VARIABLES = 24 +MAX_OFFICIAL_SERIES = 10 +MAX_PLANS = 4 +MAX_RED_LINES = 3 +MAX_GOALS = 3 +_PLAN_DIMENSIONS = ("future_plans", "likely_actions", "current_actions", "investments_capital_allocation") +_CURRENT_QUANTITATIVE_TYPES = {"", "actual", "estimate"} +_STATUS_INFERRED_PREFIXES = ("wb:", "imf:WEO/", "imf:WEO_") +_LEGACY_PLAN_KEYS = ("future_plans", "plans", "likely_actions", "current_actions", "actions_in_progress") +_LEGACY_RED_LINE_KEYS = ("red_lines", "non_negotiables") + +# --- research engine v3 rows (no plan / red-line keys; items carry no evidence_type) --------------- +# Only wording can separate the actor's documented intent from the analyst's reading there, so an +# item is promoted only when it is phrased as the actor's own commitment, as an action the actor +# pursues, or as a prohibition the actor declares. Hedged wording never qualifies. +_V3_CLAUSE_SPLIT_RE = re.compile( + r"\s*(?:;|;|。|(? str: + return sanitize_untrusted_dossier_text(value, max_chars) if value not in (None, "") else "" + + +def _dedupe(values: Iterable[str], limit: int) -> List[str]: + out: List[str] = [] + for v in values: + t = str(v or "").strip() + if t and t.casefold() not in {o.casefold() for o in out}: + out.append(t) + if len(out) >= limit: + break + return out + + +def _is_canonical(actor: Dict[str, Any]) -> bool: + intel = actor.get("intelligence") + return isinstance(intel, dict) and str(intel.get("schema_version") or "") == "actor-intelligence/v1" + + +def _pack_claims(pack: Optional[Dict[str, Any]], dimensions: Sequence[str], limit: int) -> List[str]: + """Documented (non-inference) claim texts of the actor's own validated context pack.""" + if not isinstance(pack, dict): + return [] + intel = pack.get("actor_intelligence") + dims = intel.get("dimensions") if isinstance(intel, dict) else None + if not isinstance(dims, dict): + return [] + out: List[str] = [] + for dim in dimensions: + rows = dims.get(dim) + if not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("evidence_type") or "").casefold().replace("-", "_") == "analyst_inference": + continue + if row.get("actor_knows") is False: + continue + claim = _clean(row.get("claim"), 200) + if claim: + out.append(claim) + return _dedupe(out, limit) + + +def _legacy_list(actor: Dict[str, Any], keys: Sequence[str], limit: int) -> List[str]: + out: List[str] = [] + for key in keys: + value = actor.get(key) + if isinstance(value, str): + value = [value] + if isinstance(value, dict): + value = list(value.values()) + if not isinstance(value, list): + continue + for item in value: + if isinstance(item, dict): + item = item.get("claim") or item.get("text") or item.get("action") or item.get("description") + text = _clean(item, 200) + if text: + out.append(text) + intel = actor.get("intelligence") + if isinstance(intel, dict) and not _is_canonical(actor): + for key in keys: + value = intel.get(key) + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + item = item.get("claim") or item.get("text") + text = _clean(item, 200) + if text: + out.append(text) + return _dedupe(out, limit) + + +def _v3_items(value: Any, max_chars: int = 200) -> List[str]: + """Sanitised strings of a v3 list field (strings or {claim|text} rows); never dict values.""" + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + out: List[str] = [] + for item in value: + if isinstance(item, dict): + item = item.get("claim") or item.get("text") + text = _clean(item, max_chars) + if text: + out.append(text) + return out + + +def _v3_clauses(value: Any, max_chars: int = 200) -> List[str]: + """Clauses of a v3 free-text field (``stated_vs_revealed``, ``memory``).""" + text = _clean(value, 1200) + if not text: + return [] + out: List[str] = [] + for clause in _V3_CLAUSE_SPLIT_RE.split(text): + clause = clause.strip(" ,,::.").strip() + if clause: + out.append(clause[:max_chars].rstrip()) + return out + + +def _v3_stated_clauses(actor: Dict[str, Any]) -> List[str]: + """The actor's own public statements in ``stated_vs_revealed``: clauses with stated-wording and + without revealed-wording (the revealed half is the analyst's reading of behaviour).""" + return [c for c in _v3_clauses(actor.get("stated_vs_revealed")) + if _V3_STATED_RE.search(c) and not _V3_REVEALED_RE.search(c)] + + +def _v3_is_hedged(text: str) -> bool: + return bool(_V3_HEDGE_RE.search(text)) + + +def _v3_red_line_worded(text: str) -> bool: + return bool(_V3_RED_LINE_RE.search(text)) + + +def _v3_leading_action_verb(text: str) -> bool: + head = text.lstrip("\"'“‘(([").split(" ", 1)[0].strip(",.;:").casefold() + if head in _V3_ACTION_VERBS: + return True + return any(text.startswith(v) for v in _V3_ACTION_VERBS if not v.isascii()) + + +def _v3_plan_like(text: str, *, require_commitment: bool) -> bool: + """Worded as a move the actor intends: a commitment marker (``commits to``, ``will``, + ``scheduled`` …) or — for goals — a leading action verb. Hedged wording is never a plan, and + a declared refusal ("will not accept …") is a red line, not a plan.""" + if _v3_is_hedged(text) or _v3_red_line_worded(text): + return False + if _V3_COMMIT_RE.search(text): + return True + return not require_commitment and _v3_leading_action_verb(text) + + +def derive_v3_plans(actor: Optional[Dict[str, Any]], limit: int = MAX_PLANS) -> List[str]: + """Documented plans of a research engine v3 row (no legacy plan keys): the actor's stated + commitments first, then action-phrased goals, then forward commitments in ``memory``. Hedged + wording, refusals and revealed/analyst clauses are never promoted.""" + if not isinstance(actor, dict): + return [] + out: List[str] = [] + for clause in _v3_stated_clauses(actor): + if _v3_plan_like(clause, require_commitment=True): + out.append(clause) + for goal in _v3_items(actor.get("goals")): + if _v3_plan_like(goal, require_commitment=False): + out.append(goal) + for clause in _v3_clauses(actor.get("memory")): + if _v3_plan_like(clause, require_commitment=True) and not _V3_REVEALED_RE.search(clause): + out.append(clause) + return _dedupe(out, limit) + + +def derive_v3_red_lines(actor: Optional[Dict[str, Any]], limit: int = MAX_RED_LINES) -> List[str]: + """Red lines of a research engine v3 row (no legacy red-line keys): constraints and the actor's + own statements that are phrased as non-negotiables ("will not accept", "rules out", 绝不 …). + Historical observations ("never recovered"), absences ("No clear majority") and procedural + requirements do not qualify.""" + if not isinstance(actor, dict): + return [] + out: List[str] = [] + for text in _v3_items(actor.get("constraints"), 160) + [c[:160] for c in _v3_stated_clauses(actor)]: + if _v3_red_line_worded(text) and not _v3_is_hedged(text): + out.append(text) + return _dedupe(out, limit) + + +def _declared_outcome_power(actor: Optional[Dict[str, Any]], cfg: Dict[str, Any]) -> Optional[float]: + """Only an explicitly declared power counts (I-3); visibility is never reused.""" + for source in (cfg, actor or {}): + if not isinstance(source, dict): + continue + for key in ("outcome_power", "power"): + raw = source.get(key) + if isinstance(raw, bool) or raw is None: + continue + try: + value = float(raw) + except (TypeError, ValueError): + continue + if math.isfinite(value) and value >= 0: + return value + return None + + +def _cfg_get(cfg: Any, key: str, default: Any = None) -> Any: + if isinstance(cfg, dict): + return cfg.get(key, default) + return getattr(cfg, key, default) + + +def resolve_agency_class(actor: Optional[Dict[str, Any]], entity_type: str = "", + ontology_class: str = "") -> str: + """Dossier value → ontology value → inference from type/role/description text.""" + for candidate in ((actor or {}).get("agency_class"), ontology_class): + if isinstance(candidate, str) and candidate.strip(): + klass = infer_agency_class(candidate) + if klass != "other_actor" or candidate.strip().casefold() == "other_actor": + return klass + actor = actor if isinstance(actor, dict) else {} + generic = {"", "actor", "actors", "entity", "agent", "unknown"} + type_name = entity_type if str(entity_type or "").strip().casefold() not in generic else "" + return infer_agency_class( + type_name or actor.get("type") or "", + actor.get("role"), actor.get("role_class"), actor.get("description"), actor.get("name"), + archetype=entity_archetype(actor) if actor else "", + ) + + +def state_variables_from_research(actors: Optional[Dict[str, Any]], + official_data: Optional[Dict[str, Any]] = None, *, + max_variables: int = DEFAULT_MAX_VARIABLES) -> List[Dict[str, Any]]: + """Seed variables: dossier quantitative facts first, then the official snapshot.""" + out: List[Dict[str, Any]] = [] + seen: set = set() + as_of = str((actors or {}).get("as_of_date") or "")[:10] if isinstance(actors, dict) else "" + cutoff = None + if as_of: + try: + cutoff = resolution_instant(as_of) + except (TypeError, ValueError, OverflowError): + cutoff = None + + def push(row: Dict[str, Any]) -> bool: + vid = slugify(row.get("name")) + if vid in seen or len(out) >= max_variables: + return False + seen.add(vid) + out.append({"id": vid, **row}) + return True + + for fact in extract_quantitative_rows(actors): + value_type = _clean(fact.get("value_type"), 40).casefold() + if value_type not in _CURRENT_QUANTITATIVE_TYPES: + continue + period_end = str(fact.get("period_end") or "")[:10] + if as_of and period_end and period_end > as_of: + continue + name = _clean(fact.get("metric"), 140) + if not name: + continue + number, parsed_unit = parse_quantity(fact.get("value")) + unit = _clean(fact.get("unit"), 24) or parsed_unit + row: Dict[str, Any] = { + "name": name, + "value": number if number is not None else _clean(fact.get("value"), 160), + "unit": unit if number is not None else "", + "kind": "numeric" if number is not None else "text", + "as_of": _clean(fact.get("as_of_date") or fact.get("as_of"), 40) or (as_of or None), + "provenance": "research", + } + source = _clean(fact.get("source"), 120) + definition = _clean(fact.get("definition"), 160) + rationale = "; ".join(x for x in (definition, source) if x) + if rationale: + row["rationale"] = rationale + push(row) + + series = official_data.get("series") if isinstance(official_data, dict) else None + api = data_api() + taken = 0 + for row in series if isinstance(series, list) else []: + if taken >= MAX_OFFICIAL_SERIES or len(out) >= max_variables: + break + if not isinstance(row, dict): + continue + name = _clean(row.get("title") or row.get("name") or row.get("series"), 140) + units = _clean(row.get("units"), 24) + obs = row.get("observations") + if not name or not units or not isinstance(obs, list): + continue # the official snapshot applies the same no-units exclusion + latest: Optional[Dict[str, Any]] = None + if api is not None: + try: + carrier = api.Series.from_dict(dict(row)) + except (TypeError, ValueError, KeyError, OverflowError): + carrier = None + if carrier is not None: + try: + retrieved = resolution_instant(carrier.retrieved_at) + except (TypeError, ValueError, OverflowError): + continue + if cutoff is not None and retrieved > cutoff: + continue + provenance = carrier.provenance + aggregated = bool(provenance.get("aggregation") or provenance.get("aggregation_method")) + inferred = carrier.series.startswith(_STATUS_INFERRED_PREFIXES) or "fidelity_policy_version" in provenance + if provenance.get("resolution_safe") is False and not (aggregated or inferred): + continue + for o in carrier.observations: + value = o.value + date = str(o.date or "")[:10] + if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)): + continue + if as_of and date and date > as_of: + continue + kind = api.observation_kind(o, carrier) + if kind not in ("observed", "preliminary"): + continue + if latest is None or date > str(latest.get("date") or ""): + latest = {"date": date, "value": float(value)} + else: + for o in obs: + if not isinstance(o, dict): + continue + value = o.get("value") + date = str(o.get("date") or "")[:10] + if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)): + continue + if as_of and date and date > as_of: + continue + if latest is None or date > str(latest.get("date") or ""): + latest = {"date": date, "value": float(value)} + else: + for o in obs: + if not isinstance(o, dict): + continue + value = o.get("value") + date = str(o.get("date") or "")[:10] + if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)): + continue + if as_of and date and date > as_of: + continue + if latest is None or date > str(latest.get("date") or ""): + latest = {"date": date, "value": float(value)} + if latest is None: + continue + provider = _clean(row.get("provider_name") or row.get("provider"), 80) + if push({"name": name, "value": latest["value"], "unit": units, "kind": "numeric", + "as_of": latest["date"] or None, "provenance": "official", + "rationale": provider or "official statistics"}): + taken += 1 + return out + + +def build_world_model_seed( + actors: Optional[Dict[str, Any]], + agent_configs: Sequence[Any], + *, + question: str = "", + actor_context_packs: Optional[Dict[str, Dict[str, Any]]] = None, + official_data: Optional[Dict[str, Any]] = None, + temporal_timeline: Any = None, + audience_entity_type: str = "Audience", + ontology_classes: Optional[Dict[str, str]] = None, + max_variables: int = DEFAULT_MAX_VARIABLES, + arbiter_enabled: bool = True, + max_change_ratio: float = 0.5, + barrier_timeout_s: float = 1800.0, +) -> Dict[str, Any]: + """The sealed ``world_model`` block. Degrades to a smaller block, never raises.""" + block: Dict[str, Any] = { + "schema_version": WORLD_MODEL_SEED_VERSION, + "question": _clean(question, 400), + "as_of_date": None, + "horizon_date": None, + "scenarios": [], + "state_variables": [], + "actors": [], + "relationships": [], + "scenario_indicators": [], + "settings": { + "arbiter_enabled": bool(arbiter_enabled), + "max_change_ratio": float(max_change_ratio), + "barrier_timeout_s": float(barrier_timeout_s), + }, + } + actors_dict = actors if isinstance(actors, dict) else {} + try: + block["as_of_date"] = _clean(actors_dict.get("as_of_date"), 40) or None + if temporal_timeline is not None: + block["as_of_date"] = _clean(getattr(temporal_timeline, "as_of_date", None) + or (temporal_timeline.get("as_of_date") if isinstance(temporal_timeline, dict) else None), 40) or block["as_of_date"] + block["horizon_date"] = _clean(getattr(temporal_timeline, "horizon_date", None) + or (temporal_timeline.get("horizon_date") if isinstance(temporal_timeline, dict) else None), 40) or None + fi = extract_forecast_inputs(actors_dict) + block["scenarios"] = _dedupe( + (str(s.get("name") or s.get("scenario") or "") for s in fi.get("scenarios") or []), 8) + block["scenario_indicators"] = [ + {"indicator": _clean(i.get("indicator") or i.get("name") or i.get("metric"), 160), + "direction": _clean(i.get("direction") or i.get("signal"), 60)} + for i in (fi.get("indicators") or [])[:8] + if isinstance(i, dict) and _clean(i.get("indicator") or i.get("name") or i.get("metric"), 160) + ] + except Exception: # noqa: BLE001 + pass + try: + block["state_variables"] = state_variables_from_research(actors_dict, official_data, + max_variables=max_variables) + except Exception: # noqa: BLE001 + block["state_variables"] = [] + + # actors ------------------------------------------------------------------------------ + packs = actor_context_packs if isinstance(actor_context_packs, dict) else {} + pack_by_name: Dict[str, Dict[str, Any]] = {} + for pack in packs.values(): + if isinstance(pack, dict) and pack.get("actor_name"): + pack_by_name[normalize_name(str(pack["actor_name"]))] = pack + name_to_agent: Dict[str, int] = {} + rows: List[Dict[str, Any]] = [] + for cfg in agent_configs or []: + try: + aid = _cfg_get(cfg, "agent_id") + if aid is None: + continue + aid = int(aid) + entity_type = str(_cfg_get(cfg, "entity_type", "") or "") + if entity_type == audience_entity_type: + continue + name = _clean(_cfg_get(cfg, "entity_name", "") or f"Agent_{aid}", 160) + actor = match_actor(name, actors_dict) if actors_dict else None + actor = actor if isinstance(actor, dict) else None + ontology_class = (ontology_classes or {}).get(entity_type, "") if ontology_classes else "" + agency = resolve_agency_class(actor, entity_type, ontology_class) + canonical = bool(actor and _is_canonical(actor)) + pack = pack_by_name.get(normalize_name(name)) if canonical else None + if canonical: + plans = _pack_claims(pack, _PLAN_DIMENSIONS, MAX_PLANS) + red_lines = _pack_claims(pack, ("red_lines",), MAX_RED_LINES) + goals = _pack_claims(pack, ("motivations", "values_worldview"), MAX_GOALS) + else: + plans = _legacy_list(actor or {}, _LEGACY_PLAN_KEYS, MAX_PLANS) + red_lines = _legacy_list(actor or {}, _LEGACY_RED_LINE_KEYS, MAX_RED_LINES) + goals = _legacy_list(actor or {}, ("goals", "objectives", "motivations"), MAX_GOALS) + # research engine v3 rows: no plan / red-line keys → derive from the documented wording + if actor and not plans: + plans = derive_v3_plans(actor, MAX_PLANS) + if actor and not red_lines: + red_lines = derive_v3_red_lines(actor, MAX_RED_LINES) + aliases = [] + if actor: + raw_aliases = actor.get("aliases") + if isinstance(raw_aliases, list): + aliases = _dedupe((_clean(a, 80) for a in raw_aliases), 6) + incentives = {} + for key in ("gains_if", "loses_if"): + value = _clean(_cfg_get(cfg, key, ""), 220) + if value: + incentives[key] = value + row = { + "agent_id": aid, + "name": name, + "agency_class": agency, + "archetype": entity_archetype(actor) if actor else ( + "collective" if agency == "public_collective" else "actor"), + "capacity_per_round": default_capacity(agency), + "outcome_power": _declared_outcome_power(actor, cfg if isinstance(cfg, dict) else { + "outcome_power": _cfg_get(cfg, "outcome_power")}), + "posture": _clean(_cfg_get(cfg, "stance", ""), 60), + "documented_plans": plans, + "red_lines": red_lines, + "goals": goals, + "incentives": incentives, + "aliases": aliases, + "allies": [], + "rivals": [], + } + rows.append(row) + name_to_agent[normalize_name(name)] = aid + for alias in aliases: + name_to_agent.setdefault(normalize_name(alias), aid) + except Exception: # noqa: BLE001 — one malformed agent never drops the block + continue + block["actors"] = rows + + # relationships ------------------------------------------------------------------------- + rels: List[Dict[str, Any]] = [] + by_id = {r["agent_id"]: r for r in rows} + try: + for rel in extract_relationship_rows(actors_dict): + s = name_to_agent.get(normalize_name(str(rel.get("source") or ""))) + t = name_to_agent.get(normalize_name(str(rel.get("target") or ""))) + if s is None or t is None or s == t: + continue + valence = relation_valence(rel) + polarity = relation_polarity(rel) + rels.append({ + "source_agent_id": s, "target_agent_id": t, + "source": by_id[s]["name"], "target": by_id[t]["name"], + "kind": str(rel.get("type") or "RELATES_TO").upper(), + "valence": valence, "polarity": round(polarity, 4), + "basis": _clean(rel.get("basis") or rel.get("evidence"), 160), + }) + if valence == "allied": + by_id[s]["allies"].append(by_id[t]["name"]) + by_id[t]["allies"].append(by_id[s]["name"]) + elif valence == "adversarial": + by_id[s]["rivals"].append(by_id[t]["name"]) + by_id[t]["rivals"].append(by_id[s]["name"]) + except Exception: # noqa: BLE001 + rels = [] + for row in rows: + row["allies"] = _dedupe(row["allies"], 8) + row["rivals"] = _dedupe(row["rivals"], 8) + block["relationships"] = rels[:80] + # MODELLING GAPS (ported from the world-v1 compiler's stance): what the research could not + # ground is reported, never invented — the run, the panel and the report show the list + block["gaps"] = seed_gaps(block) + return block + + +MAX_SEED_GAPS = 32 + + +def seed_gaps(block: Dict[str, Any]) -> List[Dict[str, str]]: + """Typed modelling gaps of a seed block: facts without units or dates, actors without + documented goals / plans / levers, a cast with no grounded relationships.""" + gaps: List[Dict[str, str]] = [] + + def add(kind: str, subject: str, note: str) -> None: + if len(gaps) < MAX_SEED_GAPS: + gaps.append({"kind": kind, "subject": _clean(subject, 120), "note": _clean(note, 160)}) + + for var in block.get("state_variables") or []: + if not isinstance(var, dict): + continue + name = str(var.get("name") or var.get("id") or "") + if str(var.get("kind") or "numeric") == "numeric" and not str(var.get("unit") or "").strip(): + add("variable_without_unit", name, "numeric fact without a unit; the arbiter can only move it as a bare number") + if str(var.get("provenance") or "research") in ("research", "official") and not str(var.get("as_of") or "").strip(): + add("variable_without_as_of", name, "no as-of date; staleness cannot be judged") + actors = [a for a in (block.get("actors") or []) if isinstance(a, dict)] + for a in actors: + name = str(a.get("name") or f"Agent {a.get('agent_id')}") + if a.get("agency_class") in (None, "", "other_actor"): + add("actor_unclassified", name, "no agency class inferred; falls back to the generic option catalogue") + if not a.get("goals"): + add("actor_without_goals", name, "no documented goals; decisions lean on the role brief alone") + if not a.get("documented_plans") and not a.get("red_lines"): + add("actor_without_plans", name, "no documented plans or red lines; option menu is class-generic") + if len(actors) >= 2 and not (block.get("relationships") or []): + add("no_relationships", "cast", "no grounded relationships between the actors; alliances start from scratch") + if not (block.get("state_variables") or []): + add("no_state_variables", "world", "no quantitative facts to track; the world starts without numbers") + return gaps + + +def world_model_seed_summary(block: Optional[Dict[str, Any]]) -> str: + if not isinstance(block, dict): + return "" + gaps = block.get("gaps") if isinstance(block.get("gaps"), list) else [] + return (f"world model: {len(block.get('state_variables') or [])} variables, " + f"{len(block.get('actors') or [])} actors, {len(block.get('relationships') or [])} relationships" + + (f", {len(gaps)} modelling gaps" if gaps else "")) diff --git a/backend/app/services/world_update.py b/backend/app/services/world_update.py new file mode 100644 index 00000000..79c88eaa --- /dev/null +++ b/backend/app/services/world_update.py @@ -0,0 +1,568 @@ +"""Per-round world update: deterministic kernel step plus a bounded LLM arbiter (design §2.5). + +Once every platform has delivered a round, the coordinator runs ONE world update: + +1. ``deterministic_round_update`` — scheduled research events, the actors' + structured decisions (capacity, commitments, coalition / antagonistic + relationship nudges), due delayed effects (seeded RNG) and lapsed + commitments. Never fabricates numeric effects. +2. ``run_arbiter`` — one structured LLM call that resolves contested moves, + moves state variables, emits emergent events, relationship changes and + delayed effects. Its output is validated against the variable registry and + the cast (``validate_arbiter_update``) and clamped by the kernel before being + applied. Any failure leaves the round committed with the deterministic + update only and ``arbiter_status=failed``; the failure record names the + provider's error class, HTTP status / code and whether the LLM client's + fallback provider took the call over (``arbiter_problems``). +3. ``build_round_digest`` — the agent-facing "WHAT CHANGED LAST PERIOD" text + (herding guard: no scenario shares). + +``run_world_round`` composes the three for the runner; it is pure given +``(world, decisions, llm, rng)`` and does no I/O. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from ..utils.llm_errors import describe_llm_error +from .agent_decision import LANGUAGE_MISMATCH, AgentDecision +from .world_model import (RESPONSE_PREFIXES, WorldModel, _bound_lines, _text, format_value, is_kind_name_unit, + normalize_label) + +logger = logging.getLogger(__name__) + +WORLD_UPDATE_VERSION = "world-update/v1" +ARBITER_STATUSES = ("applied", "failed", "invalid", "skipped") +RESOLUTION_OUTCOMES = ("success", "partial", "blocked", "pending", "failed") +PRESSURE_WORDS = ("up", "down", "flat") + +MAX_VARIABLE_CHANGES = 12 +MAX_NEW_VARIABLES = 3 +MAX_EVENTS = 8 +MAX_RELATIONSHIP_CHANGES = 8 +MAX_PENDING_EFFECTS = 6 +MAX_RESOLUTIONS = 24 +NARRATIVE_MAX_CHARS = 700 +DIGEST_MAX_CHARS = 1100 +ARBITER_ERROR_SNIPPET_CHARS = 200 + + +def _decision_rows(decisions: Iterable[Any]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for d in decisions or []: + if isinstance(d, AgentDecision): + rows.append(d.to_dict()) + elif isinstance(d, dict): + rows.append(d) + return rows + + +# --------------------------------------------------------------------------- +# 1. deterministic step +# --------------------------------------------------------------------------- + +def deterministic_round_update(world: WorldModel, round_num: int, period: Optional[Dict[str, Any]], + decisions: Iterable[Any], fired_events: Iterable[Dict[str, Any]], + rng: Any) -> Dict[str, Any]: + """Apply everything that follows mechanically from the round's inputs.""" + if world.round != int(round_num) or (period and world.period != period): + world.start_round(round_num, period) + scheduled = world.register_scheduled_events(round_num, fired_events) + rows = _decision_rows(decisions) + lapsed = world.lapse_overdue_commitments(round_num) + lapsed_offers = world.lapse_expired_interactions(round_num) # OFFERS: unanswered offers expire first + action_events = world.register_decisions(round_num, rows) + fired = world.fire_due_effects(round_num, rng) + breaches = [e.description for e in world.events_in_round(round_num, ("breach",))] + return { + "scheduled_events": [e.description for e in scheduled], + "actions": [e.description for e in action_events if e.kind == "action"], + "effects_fired": fired, + "commitments_lapsed": [f"{c.get('actor')}: {c.get('description')}" for c in lapsed], + "decisions": len(rows), + # OFFERS: what the consent mechanics did this period (opened / answered / lapsed / breached) + "offers_opened": [e.description for e in action_events if e.kind == "offer" and " made an " in e.description], + "offers_answered": [e.description for e in action_events + if e.kind in ("offer", "agreement") and " made an " not in e.description], + "offers_lapsed": [f"{world.actor_name(o.get('proposer'))} → {world.actor_name(o.get('recipient'))}: {o.get('terms')}" + for o in lapsed_offers], + "breaches": breaches, + } + + +# --------------------------------------------------------------------------- +# 2. arbiter +# --------------------------------------------------------------------------- + +def build_arbiter_prompt(world: WorldModel, round_num: int, period: Optional[Dict[str, Any]], + decisions: Iterable[Any], deterministic: Dict[str, Any], + scenarios: Sequence[str], *, language: str = "", + max_variables: int = 24) -> str: + """Prompt for the consequence arbiter. Variable ids are listed so the model + can reference them exactly; the scenario distribution is NOT included.""" + p = period or {} + label = str(p.get("label") or f"period {round_num}") + span = f"{p.get('period_start')} → {p.get('period_end')}" if p.get("period_end") else "" + lines: List[str] = [ + "You are the consequence arbiter of a multi-actor forecasting simulation. Each round is one " + "calendar period. Real-world actors have just made their moves for the period; your job is to " + "resolve what the world does in response — honestly, conservatively and consistently with how " + "institutions, markets and counterparties actually react. Do not invent dramatic outcomes; most " + "moves have partial, delayed or contested effects.", + f"Forecast question: {world.question or '(not stated)'}", + f"Period: {label}" + (f" ({span})" if span else "") + f" — round {round_num}" + + (f"; forecast horizon {world.horizon_date}" if world.horizon_date else ""), + "", + "## World facts (variable_id: value)", + ] + vars_sorted = sorted(world.variables.values(), + key=lambda v: (-(v.history[-1].get("round", 0) if v.history else 0), v.name)) + for v in vars_sorted[:max_variables]: + tail = f" [{v.kind}]" if v.kind != "numeric" else "" + lines.append(f"- {v.id}: {v.name} = {format_value(v.value, v.unit)}{tail}") + if not world.variables: + lines.append("- (no tracked variables yet — you may create up to 3 that the moves clearly affect)") + lines.append("") + lines.append("## Moves this period") + rows = _decision_rows(decisions) + moved = 0 + for d in rows: + if d.get("status") != "ok" or (d.get("option") or {}).get("id") == "hold_position": + continue + opt = d.get("option") or {} + tgt = f" → {', '.join(d.get('targets') or [])}" if d.get("targets") else "" + stmt = f' Statement: "{_text(d.get("public_statement"), 240)}"' if d.get("public_statement") else "" + lines.append(f"- {d.get('actor_name')}: {opt.get('label')} (kind={opt.get('kind')}, " + f"magnitude={d.get('magnitude')}){tgt}.{stmt}") + moved += 1 + if not moved: + lines.append("- (no actor made a consequential move; the world drifts on its own momentum)") + if deterministic.get("scheduled_events"): + lines.append("") + lines.append("## Confirmed events this period") + lines.extend(f"- {e}" for e in deterministic["scheduled_events"][:10]) + mech: List[str] = [] + for fx in deterministic.get("effects_fired") or []: + mech.append(fx.get("description", "")) + mech.extend(deterministic.get("commitments_lapsed") or []) + if mech: + lines.append("") + lines.append("## Already applied mechanically") + lines.extend(f"- {m}" for m in mech[:10] if m) + deals = (list(deterministic.get("offers_opened") or []) + list(deterministic.get("offers_answered") or []) + + [f"Lapsed: {o}" for o in (deterministic.get("offers_lapsed") or [])] + + list(deterministic.get("breaches") or [])) + open_offers = [it for it in world.interactions.values() if it.is_open()] + if deals or open_offers: + lines.append("") + lines.append("## Offers and agreements (settled only by the parties' answers — never assume consent)") + lines.extend(f"- {d}" for d in deals[:8]) + lines.extend(f"- Still open: {world.actor_name(it.proposer)} → {world.actor_name(it.recipient)} " + f"[{it.id}]: {it.terms}" for it in open_offers[:6]) + cast = ", ".join(a.name for a in list(world.actors.values())[:30]) + sc = "; ".join(str(s) for s in scenarios) or "(none)" + lang = f" Write narrative and descriptions in {language}." if language else "" + lines += [ + "", + "## Your task", + "Resolve the period. Return ONLY JSON with these keys:", + '- "narrative": 2-4 sentences on what actually changed and why (≤600 chars).', + '- "variable_changes": [{"variable_id": "", "new_value": OR ' + '"delta": , "rationale": "..."}] — only variables the moves/events plausibly affect; ' + "small, defensible moves; leave the rest untouched.", + '- "new_variables": [{"name": "...", "value": ..., "unit": "...", "kind": "numeric|categorical|boolean|text", ' + '"rationale": "..."}] — at most 3, only when a move creates a new fact the world must now track.', + '- "events": [{"description": "...", "kind": "emergent|consequence|resolution", "actors": [""], ' + '"targets": [""]}] — market, counterpart, public or institutional reactions (≤6).', + '- "relationship_changes": [{"source": "", "target": "", "direction": "closer|farther", ' + '"rationale": "..."}].', + '- "pending_effects": [{"description": "...", "due_periods": 1-12, "variable_id": "", ' + '"new_value": ... OR "delta": ..., "probability": 0-1}] — delayed consequences that land later.', + '- "action_resolutions": [{"actor": "", "outcome": "success|partial|blocked|pending|failed", ' + '"rationale": "..."}] — one per actor that moved.', + '- "scenario_pressure": {"": "up|down|flat"} — direction only, no numbers.', + f"Cast: {cast}. Candidate scenarios: {sc}.{lang}", + ] + return "\n".join(lines) + + +def _num(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + out = float(value) + return out if math.isfinite(out) else None + try: + out = float(str(value).replace(",", "").replace("%", "").strip()) + return out if math.isfinite(out) else None + except (TypeError, ValueError): + return None + + +def validate_arbiter_update(raw: Any, world: WorldModel, scenarios: Sequence[str] = ()) -> Tuple[Dict[str, Any], List[str]]: + """Bound and type-check the arbiter payload; returns ``(update, problems)``. + + Unknown variable ids, unknown actors, bad kinds and oversized lists are + dropped with a recorded problem. The returned update is safe to hand to + ``WorldModel.apply_arbiter_update`` (which additionally clamps numeric moves). + """ + problems: List[str] = [] + update: Dict[str, Any] = {"narrative": "", "variable_changes": [], "new_variables": [], "events": [], + "relationship_changes": [], "pending_effects": [], "action_resolutions": [], + "scenario_pressure": {}} + if not isinstance(raw, dict): + return update, ["payload is not an object"] + update["narrative"] = _text(raw.get("narrative"), NARRATIVE_MAX_CHARS) + changes = raw.get("variable_changes") + if isinstance(changes, list): + changed_ids: set = set() + for ch in changes[:MAX_VARIABLE_CHANGES]: + if not isinstance(ch, dict): + continue + vid = str(ch.get("variable_id") or ch.get("id") or "").strip() + var = world.variables.get(vid) + if var is None: + # tolerate the model naming the variable instead of its id + wanted = normalize_label(vid) + var = next((v for v in world.variables.values() if normalize_label(v.name) == wanted), None) + if var is None: + problems.append(f"unknown variable '{vid}'") + continue + if var.id in changed_ids: + problems.append(f"duplicate change for '{var.id}'") + continue + row: Dict[str, Any] = {"variable_id": var.id, "rationale": _text(ch.get("rationale"), 200)} + if var.kind == "numeric": + nv, dl = _num(ch.get("new_value")), _num(ch.get("delta")) + if nv is None and dl is None: + problems.append(f"non-numeric change for numeric variable '{var.id}'") + continue + if nv is not None: + row["new_value"] = nv + else: + row["delta"] = dl + elif var.kind == "boolean": + val = ch.get("new_value") + if not isinstance(val, bool): + low = _text(val, 16).casefold() + if low in ("true", "yes", "是"): + val = True + elif low in ("false", "no", "否"): + val = False + else: + problems.append(f"non-boolean change for '{var.id}'") + continue + row["new_value"] = val + else: + val = _text(ch.get("new_value"), 160) + if not val: + problems.append(f"empty value for '{var.id}'") + continue + row["new_value"] = val + update["variable_changes"].append(row) + changed_ids.add(var.id) + new_vars = raw.get("new_variables") + if isinstance(new_vars, list): + for nv in new_vars[:MAX_NEW_VARIABLES]: + if not isinstance(nv, dict) or not _text(nv.get("name"), 140): + continue + raw_kind = _text(nv.get("kind"), 16).casefold() + kind = raw_kind or "numeric" + if kind not in ("numeric", "categorical", "boolean", "text"): + kind = "text" + if kind == "numeric" and _num(nv.get("value")) is None: + kind = "text" + unit = _text(nv.get("unit"), 24) + # a unit belongs to numeric facts only; models echo the kind ("boolean", "date") + # into the unit field, which would otherwise render as "yes boolean" + if kind != "numeric" or unit.casefold() in (kind, raw_kind) or is_kind_name_unit(unit): + unit = "" + update["new_variables"].append({"name": _text(nv["name"], 140), "value": nv.get("value"), + "unit": unit, "kind": kind, + "rationale": _text(nv.get("rationale"), 200)}) + if len(new_vars) > MAX_NEW_VARIABLES: + problems.append(f"{len(new_vars) - MAX_NEW_VARIABLES} new variables dropped (cap {MAX_NEW_VARIABLES})") + + def _names(values: Any) -> List[str]: + out: List[str] = [] + if isinstance(values, list): + for v in values[:6]: + aid = world.resolve_actor(v) + if aid is not None: + name = world.actor_name(aid) + if name not in out: + out.append(name) + elif _text(v, 80): + problems.append(f"unknown actor '{_text(v, 80)}'") + return out + + events = raw.get("events") + if isinstance(events, list): + for ev in events[:MAX_EVENTS]: + if not isinstance(ev, dict) or not _text(ev.get("description"), 320): + continue + kind = _text(ev.get("kind"), 16).casefold() + if kind not in ("emergent", "consequence", "resolution"): + kind = "emergent" + var_ids = ev.get("variable_ids") + if not isinstance(var_ids, list): + var_ids = [] + update["events"].append({"description": _text(ev["description"], 320), "kind": kind, + "actors": _names(ev.get("actors")), "targets": _names(ev.get("targets")), + "variable_ids": [str(x) for x in var_ids + if str(x) in world.variables][:6]}) + rels = raw.get("relationship_changes") + if isinstance(rels, list): + for rc in rels[:MAX_RELATIONSHIP_CHANGES]: + if not isinstance(rc, dict): + continue + a, b = world.resolve_actor(rc.get("source")), world.resolve_actor(rc.get("target")) + direction = _text(rc.get("direction"), 16).casefold() + if a is None or b is None or a == b: + problems.append("relationship change with unknown or identical actors") + continue + if direction not in ("closer", "farther", "further", "break", "ally"): + problems.append(f"unknown relationship direction '{direction}'") + continue + update["relationship_changes"].append({"source": world.actor_name(a), "target": world.actor_name(b), + "direction": direction, "rationale": _text(rc.get("rationale"), 200)}) + effects = raw.get("pending_effects") + if isinstance(effects, list): + for fx in effects[:MAX_PENDING_EFFECTS]: + if not isinstance(fx, dict) or not _text(fx.get("description"), 220): + continue + try: + due = max(1, min(12, int(fx.get("due_periods", 1) or 1))) + except (TypeError, ValueError): + due = 1 + vid = fx.get("variable_id") + vid = str(vid) if vid and str(vid) in world.variables else None + row = {"description": _text(fx["description"], 220), "due_periods": due, "variable_id": vid, + "targets": _names(fx.get("targets"))} + prob = _num(fx.get("probability")) + row["probability"] = max(0.0, min(1.0, prob)) if prob is not None else 1.0 + if vid is not None: + var = world.variables[vid] + if var.kind == "numeric": + nv, dl = _num(fx.get("new_value")), _num(fx.get("delta")) + if nv is not None: + row["new_value"] = nv + elif dl is not None: + row["delta"] = dl + else: + row["variable_id"] = None + else: + row["new_value"] = _text(fx.get("new_value"), 160) or None + if row["new_value"] is None: + row["variable_id"] = None + update["pending_effects"].append(row) + res = raw.get("action_resolutions") + if isinstance(res, list): + for r in res[:MAX_RESOLUTIONS]: + if not isinstance(r, dict): + continue + aid = world.resolve_actor(r.get("actor") if r.get("actor") is not None else r.get("agent_id")) + outcome = _text(r.get("outcome"), 16).casefold() + if aid is None or outcome not in RESOLUTION_OUTCOMES: + problems.append("resolution with unknown actor or outcome") + continue + update["action_resolutions"].append({"actor": world.actor_name(aid), "outcome": outcome, + "rationale": _text(r.get("rationale"), 200)}) + pressure = raw.get("scenario_pressure") + if isinstance(pressure, dict): + for k, v in pressure.items(): + word = _text(v, 8).casefold() + if word not in PRESSURE_WORDS: + continue + key = normalize_label(k) + for s in scenarios: + if normalize_label(s) == key or (len(key) >= 4 and key in normalize_label(s)): + update["scenario_pressure"][str(s)] = word + break + return update, problems + + +def run_arbiter(llm: Any, world: WorldModel, round_num: int, period: Optional[Dict[str, Any]], + decisions: Iterable[Any], deterministic: Dict[str, Any], scenarios: Sequence[str], *, + language: str = "", temperature: float = 0.3, max_tokens: int = 2200 + ) -> Tuple[str, Dict[str, Any], List[str]]: + """One arbiter call → ``(status, validated_update, problems)``; never raises. + + ``llm.chat_json`` is the backend ``LLMClient`` surface: on a content-filter or other + non-retryable 4xx rejection the client itself retries the request once on the + configured fallback provider (``LLMClient.chat`` failover) before raising. A failure + that survives that is recorded with the provider's error class, HTTP status / code, a + bounded message snippet and the failover outcome, so ``arbiter_problems`` says why + the round carries only the deterministic update. + """ + if llm is None: + return "skipped", {}, ["no llm"] + prompt = build_arbiter_prompt(world, round_num, period, decisions, deterministic, scenarios, language=language) + try: + raw = llm.chat_json(messages=[{"role": "user", "content": prompt}], temperature=temperature, + max_tokens=max_tokens) + except Exception as exc: # noqa: BLE001 — a failed arbiter must not break the round + detail = describe_llm_error(exc, max_chars=ARBITER_ERROR_SNIPPET_CHARS) + logger.warning("world arbiter failed in round %s (status=failed): %s", round_num, detail) + return "failed", {}, [f"llm error: {detail}"] + try: + update, problems = validate_arbiter_update(raw, world, scenarios) + except Exception as exc: # noqa: BLE001 — malformed payload must not break round commit + logger.warning("world arbiter validation failed in round %s: %s", round_num, exc) + return "invalid", {}, [f"validation error: {type(exc).__name__}"] + if not isinstance(raw, dict): + return "invalid", update, problems + return "applied", update, problems + + +# --------------------------------------------------------------------------- +# 3. digest and headline +# --------------------------------------------------------------------------- + +def build_round_digest(world: WorldModel, round_num: int, previous: Optional[Dict[str, Any]], + decisions: Iterable[Any], narrative: str = "", *, + max_chars: int = DIGEST_MAX_CHARS) -> str: + """Agent-facing WHAT CHANGED LAST PERIOD text (no scenario shares, I-1).""" + lines: List[str] = [] + if narrative: + lines.append(_text(narrative, 360)) + rows = _decision_rows(decisions) + moves = [d for d in rows if d.get("status") == "ok" and (d.get("option") or {}).get("id") != "hold_position"] + if moves: + lines.append("Moves:") + for d in moves[:8]: + opt = d.get("option") or {} + tgt = f" (→ {', '.join(d.get('targets') or [])})" if d.get("targets") else "" + stmt = f': "{_text(d.get("public_statement"), 140)}"' if d.get("public_statement") else "" + lines.append(f"- {d.get('actor_name')}: {opt.get('label')}{tgt}{stmt}") + holders = [d.get("actor_name") for d in rows if d.get("status") == "ok" + and (d.get("option") or {}).get("id") == "hold_position"] + if holders: + lines.append("Held position: " + ", ".join(str(h) for h in holders[:8])) + diff = world.diff(previous) if previous is not None else {"variables_changed": [], "variables_added": [], + "relationships_changed": []} + if diff["variables_changed"] or diff["variables_added"]: + lines.append("Facts that changed:") + for ch in diff["variables_changed"][:8]: + lines.append(f"- {ch['name']}: {format_value(ch['from'], ch['unit'])} → {format_value(ch['to'], ch['unit'])}") + for nv in diff["variables_added"][:4]: + lines.append(f"- New: {nv['name']} = {format_value(nv['value'], nv['unit'])}") + consequences = world.events_in_round(round_num, ("scheduled", "consequence", "emergent", "resolution")) + if consequences: + lines.append("Events and consequences:") + lines.extend(f"- {e.description}" for e in consequences[:8]) + deals = world.events_in_round(round_num, ("offer", "agreement", "breach")) + if deals: + lines.append("Offers and agreements:") + lines.extend(f"- {e.description}" for e in deals[:8]) + if diff["relationships_changed"]: + lines.append("Alliances and rivalries:") + for rc in diff["relationships_changed"][:6]: + direction = f" ({rc['direction']})" if rc.get("direction") else "" + lines.append(f"- {rc['source']} — {rc['target']}: {rc['to']}{direction}") + return _bound_lines(lines, max_chars) + + +def behaviour_metrics(world: WorldModel, round_num: int, decisions: Iterable[Any]) -> Dict[str, Any]: + """Deterministic responsiveness diagnostics for one round (ported, in spirit, from the + world-v1 behaviour evaluation's "consequence response" criterion — without an LLM judge). + + A move is *responsive* when it answers an offer, or when its public statement / rationale + names a fact that changed last period or an actor that moved last period. Round 1 has no + prior period and scores nothing. This is a textual diagnostic, not a realism verdict.""" + rows = _decision_rows(decisions) + prev = int(round_num) - 1 + moves = [d for d in rows if d.get("status") == "ok" and (d.get("option") or {}).get("id") != "hold_position"] + out: Dict[str, Any] = {"moves": len(moves), "responsive_moves": 0, "offers_answered": 0, + "responsive_share": None, "signals_last_period": 0} + if prev < 1: + return out + changed = [v.name.casefold() for v in world.variables.values() + if any(isinstance(h, dict) and int(h.get("round") or 0) == prev for h in v.history)] + movers = set() + for e in world.events_in_round(prev, ("action", "offer", "agreement", "breach", "consequence", "emergent")): + for name in e.actors[:2]: + movers.add(name.casefold()) + out["signals_last_period"] = len(changed) + len(movers) + for d in moves: + option_id = str((d.get("option") or {}).get("id") or "") + text = f"{d.get('private_rationale') or ''} {d.get('public_statement') or ''}".casefold() + me = str(d.get("actor_name") or "").casefold() + if option_id.startswith(RESPONSE_PREFIXES): + out["offers_answered"] += 1 + out["responsive_moves"] += 1 + elif any(name and name in text for name in changed) or any(m and m != me and m in text for m in movers): + out["responsive_moves"] += 1 + if moves: + out["responsive_share"] = round(out["responsive_moves"] / len(moves), 3) + return out + + +def world_update_headline(world: WorldModel, round_num: int, record: Dict[str, Any]) -> str: + """One line for the action log / run state: period + counts.""" + p = world.period or {} + label = str(p.get("label") or f"round {round_num}") + det = record.get("deterministic") or {} + arb = record.get("arbiter") or {} + bits = [f"{label}: {det.get('decisions', 0)} decisions", + f"{len((record.get('diff') or {}).get('variables_changed') or [])} facts changed", + f"{arb.get('events', 0)} events", f"arbiter={record.get('arbiter_status')}"] + narrative = _text(record.get("narrative"), 140) + if narrative: + bits.append(narrative) + return " · ".join(bits) + + +# --------------------------------------------------------------------------- +# composition +# --------------------------------------------------------------------------- + +def run_world_round(world: WorldModel, round_num: int, period: Optional[Dict[str, Any]], + decisions: Iterable[Any], fired_events: Iterable[Dict[str, Any]], *, llm: Any, rng: Any, + scenarios: Sequence[str] = (), arbiter_enabled: bool = True, language: str = "") -> Dict[str, Any]: + """Deterministic update → arbiter → commit → digest. Returns the round record.""" + rows = _decision_rows(decisions) + before = world.snapshot() + deterministic = deterministic_round_update(world, round_num, period, rows, fired_events, rng) + status, update, problems = ("skipped", {}, []) + arbiter_summary: Dict[str, Any] = {} + if arbiter_enabled: + status, update, problems = run_arbiter(llm, world, round_num, period, rows, deterministic, scenarios, + language=language) + if status in ("applied", "invalid") and update: + try: + arbiter_summary = world.apply_arbiter_update(round_num, update) + except Exception as exc: # noqa: BLE001 — a kernel bug must not break the loop + logger.warning("applying arbiter update failed in round %s: %s", round_num, exc) + status = "failed" + problems.append(f"apply error: {describe_llm_error(exc, max_chars=120)}") + world.commit_round(round_num) + diff = world.diff(before) + narrative = str(update.get("narrative") or "") if isinstance(update, dict) else "" + digest = build_round_digest(world, round_num, before, rows, narrative) + record: Dict[str, Any] = { + "schema_version": WORLD_UPDATE_VERSION, + "round": int(round_num), + "period": dict(period) if isinstance(period, dict) else None, + "decisions": {"total": len(rows), + "ok": sum(1 for d in rows if d.get("status") == "ok"), + "held": sum(1 for d in rows if (d.get("option") or {}).get("id") == "hold_position"), + "failed": sum(1 for d in rows if d.get("status") == "failed"), + "invalid": sum(1 for d in rows if d.get("status") == "invalid"), + LANGUAGE_MISMATCH: sum(1 for d in rows if d.get("status") == LANGUAGE_MISMATCH)}, + "deterministic": deterministic, + "arbiter_status": status, + "arbiter_problems": problems[:12], + "arbiter": arbiter_summary, + "scenario_pressure": dict(update.get("scenario_pressure") or {}) if isinstance(update, dict) else {}, + "narrative": narrative, + "diff": diff, + "digest": digest, + "behaviour": behaviour_metrics(world, round_num, rows), + "snapshot_before": before, + } + record["headline"] = world_update_headline(world, round_num, record) + return record diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 4802e58d..39801016 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -20,6 +20,7 @@ from typing import Optional, Dict, Any, List from ..config import Config +from .llm_errors import describe_llm_error, is_content_filter_error from .logger import get_logger logger = get_logger('mirofish.llm_client') @@ -92,7 +93,7 @@ def _err_brief(exc: Exception) -> str: """Short, classified error description for failover logging (S9).""" s = str(exc) low = s.lower() - if "new_sensitive" in low or "content" in low and "filter" in low: + if is_content_filter_error(exc): kind = "content-filter(422)" elif "429" in s or "rate_limit" in low or "quota" in low or "usage limit" in low: kind = "quota/rate-limit(429)" @@ -124,9 +125,23 @@ def _err_brief(exc: Exception) -> str: def _is_content_filter(exc: Exception) -> bool: - s = str(exc).lower() - return ("new_sensitive" in s or "unprocessable" in s - or ("content" in s and "filter" in s) or " 422" in s or "code: 422" in s) + """Content-moderation rejection (MiniMax 422 new_sensitive, Zhipu 400/1301, OpenAI + content_filter …); the shared classification lives in ``llm_errors``.""" + return is_content_filter_error(exc) + + +def _note_failover(exc: Optional[BaseException], note: str) -> None: + """Attach the failover outcome to the primary error that is about to propagate + (``BaseException.add_note``), so callers' failure records can say whether the fallback + provider took the call over and, if not, why (unconfigured / skipped / failed). One + note per error; a note is a courtesy and never becomes a new failure.""" + if exc is None: + return + try: + if not any(str(n).startswith("fallback") for n in (getattr(exc, "__notes__", None) or [])): + exc.add_note(note) + except Exception: # noqa: BLE001 + pass # LLM-3: 429/quota 熔断(与 422 熔断共用 tripped_until)。MiniMax 硬配额耗尽后 ~1h 内每次调用 @@ -442,8 +457,8 @@ def chat( # Circuit breaker: if the primary is in a content-filter/quota cooldown, skip the doomed # primary attempt entirely and go straight to the fallback (prevents the futile-call flood). if _cb_tripped(self.provider) and not self._is_fallback: - _fb = self._try_fallback(messages, temperature, max_tokens, response_format, - RuntimeError(f"circuit-breaker: {self.provider} in 422/429 cooldown")) + _cb_err = RuntimeError(f"circuit-breaker: {self.provider} in 422/429 cooldown") + _fb = self._try_fallback(messages, temperature, max_tokens, response_format, _cb_err) if _fb is not None: result = _fb served_by_fallback = True @@ -452,10 +467,13 @@ def chat( # 完整的 3 次主重试(指数退避睡眠)+ 第二次回退 —— 双中断期间每次 chat() # 白烧 5 次注定失败的调用(2026-07-08 实测:最高 231 错误/分钟持续 26 小时)。 # 直接抛出(复用穷尽路径的异常类型),让调用方快速失败。 - raise RuntimeError( + _err = RuntimeError( f"LLM 调用失败:主提供方 {self.provider} 处于 422/429 熔断冷却," f"且回退提供方不可用" ) + for _note in getattr(_cb_err, "__notes__", None) or []: + _note_failover(_err, str(_note)) + raise _err for attempt in range(MAX_RETRIES): if result is not None: break @@ -528,9 +546,14 @@ def _try_fallback(self, messages: List[Dict[str, str]], temperature: float, primary exhausts retries / hits a content-filter. Off unless LLM_FALLBACK_PROVIDER is set; never recurses (the fallback client has failover disabled). Returns text or None.""" if getattr(self, "_is_fallback", False): - return None + return None # the fallback client never recurses (its error is reported by the primary) fb_provider = (os.environ.get("LLM_FALLBACK_PROVIDER", "") or "").strip().lower() - if not fb_provider or fb_provider == self.provider: + if not fb_provider: + _note_failover(primary_error, "fallback: not configured (LLM_FALLBACK_PROVIDER unset)") + return None + if fb_provider == self.provider: + _note_failover(primary_error, + f"fallback: skipped (LLM_FALLBACK_PROVIDER={fb_provider} is the primary provider)") return None fb_model = (os.environ.get("LLM_FALLBACK_MODEL", "") or None) fb_base_url = (os.environ.get("LLM_FALLBACK_BASE_URL", "") or None) @@ -549,6 +572,7 @@ def _try_fallback(self, messages: List[Dict[str, str]], temperature: float, "(会导致必然的 400 invalid-model);请设置 LLM_FALLBACK_MODEL", fb_provider, Config.LLM_MODEL_NAME, ) + _note_failover(primary_error, f"fallback {fb_provider}: skipped (LLM_FALLBACK_MODEL unset)") return None auth_key = (fb_provider, fb_model or "", fb_base_url or "") with _CB_LOCK: @@ -558,6 +582,7 @@ def _try_fallback(self, messages: List[Dict[str, str]], temperature: float, "Skipping fallback provider %s during deterministic-failure cooldown", fb_provider, ) + _note_failover(primary_error, f"fallback {fb_provider}: skipped (deterministic-failure cooldown)") return None try: fb = LLMClient( @@ -592,6 +617,8 @@ def _try_fallback(self, messages: List[Dict[str, str]], temperature: float, time.monotonic() + _FB_AUTH_COOLDOWN_S ) logger.error(f"回退提供方 {fb_provider} 也失败: {_err_brief(e)}") + _note_failover(primary_error, + f"fallback {fb_provider}: failed ({describe_llm_error(e, max_chars=80)})") return None def chat_json( diff --git a/backend/app/utils/llm_errors.py b/backend/app/utils/llm_errors.py new file mode 100644 index 00000000..f0073f55 --- /dev/null +++ b/backend/app/utils/llm_errors.py @@ -0,0 +1,169 @@ +"""Provider-agnostic description of LLM call failures (pure; no SDK imports). + +Shared by ``app.utils.llm_client`` (failover classification and logging) and by the +simulation's world update / decision steps, whose failure records must say *what* +the provider rejected — exception class, HTTP status, provider error code, +content-filter classification and a bounded message snippet — instead of only +the exception type (the live run ``sim_d6ebcc77e22d`` recorded +``llm error: BadRequestError`` for two Zhipu content-filter rejections, which +hid the HTTP 400 / code 1301 cause). + +The helpers inspect the exception duck-typed (``status_code``, ``body``, +``code``, ``response`` and the message text), so they work for the OpenAI SDK +error classes, ``RuntimeError`` wrappers from CLI providers and stub exceptions +in tests alike. ``describe_llm_error`` also surfaces the failover note that +``LLMClient.chat`` attaches when the configured fallback provider could not +take the call over (``BaseException.add_note``). +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + +# Provider-specific content-moderation rejections that arrive as a plain HTTP 400. +# Zhipu / GLM 1301: 系统检测到输入或生成内容可能包含不安全或敏感内容. +CONTENT_FILTER_CODES = frozenset({"1301", "content_filter", "new_sensitive"}) +_CONTENT_FILTER_MARKERS = ( + "new_sensitive", "contentfilter", "content_filter", "content filter", "content-filter", + "unprocessable", "sensitive content", "敏感内容", "内容审查", +) +# 4xx statuses that are worth retrying (timeouts / rate limits); every other 4xx is a +# deterministic rejection of this request. +_RETRYABLE_4XX = frozenset({408, 425, 429}) +_HTTP_STATUS_RE = re.compile(r"(?i)(?:error code|status(?: code)?|http)[:\s]+(\d{3})\b") +# ``'code': '1301'`` / ``"code": "content_filter"`` inside a serialised provider body; the +# quoted key excludes the SDK's own ``Error code: 400`` prefix. +_BODY_CODE_RE = re.compile(r"""['"]code['"]\s*:\s*['"]?([A-Za-z0-9_.-]{1,32})""") +_QUOTA_MARKERS = ("rate_limit", "rate limit", "quota", "usage limit", "insufficient balance", "429") +_AUTH_MARKERS = ("authenticationerror", "invalid authentication", "failed to authenticate", "401") + +DEFAULT_SNIPPET_CHARS = 160 + + +def _message(exc: BaseException) -> str: + try: + text = str(exc) + except Exception: # noqa: BLE001 — a broken __str__ must not mask the real failure + text = "" + if not text: + text = repr(exc) + return " ".join(text.split()) + + +def error_status(exc: BaseException) -> Optional[int]: + """HTTP status of a provider error (``status_code`` attribute, response or message).""" + for candidate in (getattr(exc, "status_code", None), + getattr(getattr(exc, "response", None), "status_code", None)): + try: + if candidate is not None and not isinstance(candidate, bool): + value = int(candidate) + if 100 <= value <= 599: + return value + except (TypeError, ValueError): + continue + match = _HTTP_STATUS_RE.search(_message(exc)) + if match: + value = int(match.group(1)) + if 100 <= value <= 599: + return value + return None + + +def error_code(exc: BaseException) -> str: + """Provider error code (``body.error.code`` for OpenAI-style bodies, MiniMax + ``base_resp.status_code``, the SDK ``code`` attribute or the serialised body).""" + body = getattr(exc, "body", None) + if isinstance(body, dict): + err = body.get("error") + if isinstance(err, dict) and err.get("code") not in (None, ""): + return str(err["code"]) + if body.get("code") not in (None, ""): + return str(body["code"]) + base = body.get("base_resp") + if isinstance(base, dict) and base.get("status_code") not in (None, "", 0): + return str(base["status_code"]) + code = getattr(exc, "code", None) + if code not in (None, "") and not isinstance(code, bool): + return str(code) + match = _BODY_CODE_RE.search(_message(exc)) + return match.group(1) if match else "" + + +def is_content_filter_error(exc: BaseException) -> bool: + """Content-moderation rejection of the request (MiniMax 422 new_sensitive, Zhipu 400/1301, + OpenAI ``content_filter`` …): retrying the same provider cannot help.""" + low = _message(exc).lower() + if any(marker in low for marker in _CONTENT_FILTER_MARKERS): + return True + if " 422" in low or "code: 422" in low or error_status(exc) == 422: + return True + if error_code(exc).lower() in CONTENT_FILTER_CODES: + return True + body = getattr(exc, "body", None) + return isinstance(body, dict) and "contentFilter" in body + + +def is_non_retryable_request_error(exc: BaseException) -> bool: + """A deterministic 4xx rejection (content filter, bad request, auth, not found …) — + the one error class where only a different provider can still answer.""" + if is_content_filter_error(exc): + return True + status = error_status(exc) + return status is not None and 400 <= status <= 499 and status not in _RETRYABLE_4XX + + +def classify_llm_error(exc: BaseException) -> str: + """Short failure class for logs and records: ``content filter`` / ``quota/rate limit`` / + ``authentication`` / ``bad request`` / ``""`` (unclassified).""" + if is_content_filter_error(exc): + return "content filter" + status = error_status(exc) + low = _message(exc).lower() + if status == 429 or any(marker in low for marker in _QUOTA_MARKERS): + return "quota/rate limit" + if status in (401, 403) or any(marker in low for marker in _AUTH_MARKERS): + return "authentication" + if status is not None and 400 <= status <= 499: + return "bad request" + return "" + + +def failover_notes(exc: BaseException) -> str: + """Notes attached by ``LLMClient.chat`` about the fallback provider (``add_note``).""" + notes = getattr(exc, "__notes__", None) + if not isinstance(notes, list): + return "" + return "; ".join(" ".join(str(n).split()) for n in notes if str(n).strip()) + + +def llm_error_details(exc: BaseException, *, max_chars: int = DEFAULT_SNIPPET_CHARS) -> Dict[str, Any]: + """Structured view: ``{type, status, code, kind, failover, message}``.""" + return { + "type": type(exc).__name__, + "status": error_status(exc), + "code": error_code(exc), + "kind": classify_llm_error(exc), + "failover": failover_notes(exc), + "message": _message(exc)[: max(16, int(max_chars))], + } + + +def describe_llm_error(exc: BaseException, *, max_chars: int = DEFAULT_SNIPPET_CHARS) -> str: + """One bounded line for failure records, e.g. + ``BadRequestError (HTTP 400, provider code 1301, content filter; fallback: not configured): + Error code: 400 - {'contentFilter': …}``.""" + d = llm_error_details(exc, max_chars=max_chars) + parts = [] + if d["status"] is not None: + parts.append(f"HTTP {d['status']}") + if d["code"]: + parts.append(f"provider code {d['code']}") + if d["kind"]: + parts.append(d["kind"]) + head = d["type"] + if parts: + head += " (" + ", ".join(parts) + (f"; {d['failover']}" if d["failover"] else "") + ")" + elif d["failover"]: + head += f" ({d['failover']})" + return f"{head}: {d['message']}" if d["message"] else head diff --git a/backend/scripts/action_logger.py b/backend/scripts/action_logger.py index cf389598..508b43aa 100644 --- a/backend/scripts/action_logger.py +++ b/backend/scripts/action_logger.py @@ -15,10 +15,27 @@ import json import os import logging +import sys from datetime import datetime from typing import Dict, Any, Optional +def _stdio_points_at(path: str) -> bool: + """True when this process's stdout or stderr already is the file at ``path``: the launching + backend (app/services/simulation_runner.py) captures the runner's console into simulation.log.""" + try: + target = os.stat(path) + except OSError: + return False + for stream in (sys.stdout, sys.stderr): + try: + if os.path.samestat(os.fstat(stream.fileno()), target): + return True + except (AttributeError, OSError, ValueError): + continue + return False + + class PlatformActionLogger: """单平台动作日志记录器""" @@ -102,6 +119,24 @@ def log_round_end(self, round_num: int, actions_count: int, with open(self.log_path, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') + def log_world_update(self, round_num: int, headline: str, **fields: Any): + """REFLEXIVE WORLD: one row per committed world update (round is 1-based). + + The backend monitor projects ``headline``/``period_label``/``arbiter_status`` + into run_state.options["world_model"]; hours mode never emits this event.""" + entry: Dict[str, Any] = { + "round": round_num, + "timestamp": datetime.now().isoformat(), + "event_type": "world_update", + "headline": str(headline or ""), + } + for key, value in fields.items(): + if value is not None: + entry[key] = value + + with open(self.log_path, 'a', encoding='utf-8') as f: + f.write(json.dumps(entry, ensure_ascii=False) + '\n') + def log_simulation_start(self, config: Dict[str, Any], total_rounds: int): """记录模拟开始。total_rounds 由调用方显式给出——旧实现按 total_simulation_hours*2 硬编码推算,早已与真实轮数公式 @@ -153,16 +188,28 @@ def __init__(self, simulation_dir: str): self._setup_main_logger() def _setup_main_logger(self): - """设置主模拟日志""" + """设置主模拟日志。 + + The launching backend streams this process's stdout/stderr into the same ``simulation.log`` + through its own append-only handle (SimulationRunner._open_main_log), so the file handler + appends as well — a second non-appending writer overwrites the other's bytes at its private + offset and the world-model round lines came out torn or missing. The file is truncated only + when no standard stream already points at it (a standalone run starts a fresh log; a + backend capture is never discarded), and the console handler is skipped when stderr IS the + log file, where it would only repeat every record.""" log_path = os.path.join(self.simulation_dir, "simulation.log") + shared = _stdio_points_at(log_path) + if not shared: + with open(log_path, 'w', encoding='utf-8'): + pass # 创建 logger self._main_logger = logging.getLogger(f"simulation.{os.path.basename(self.simulation_dir)}") self._main_logger.setLevel(logging.INFO) self._main_logger.handlers.clear() - # 文件处理器 - file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w') + # 文件处理器(追加写:与后端捕获的 stdout/stderr 共用同一文件) + file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='a') file_handler.setLevel(logging.INFO) file_handler.setFormatter(logging.Formatter( '%(asctime)s - %(levelname)s - %(message)s', @@ -170,14 +217,15 @@ def _setup_main_logger(self): )) self._main_logger.addHandler(file_handler) - # 控制台处理器 - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(logging.Formatter( - '[%(asctime)s] %(message)s', - datefmt='%H:%M:%S' - )) - self._main_logger.addHandler(console_handler) + # 控制台处理器(stderr 已经就是日志文件时省略,避免每条记录写两遍) + if not shared: + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter( + '[%(asctime)s] %(message)s', + datefmt='%H:%M:%S' + )) + self._main_logger.addHandler(console_handler) self._main_logger.propagate = False diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 8a3cfca8..bcf6a3ec 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -64,6 +64,16 @@ def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, builtins.open = _utf8_open +# stdout 按行刷新(所有平台):后端把本进程的 stdout/stderr 捕获进 simulation.log(与主日志 +# 记录器 SimulationLogManager 同一文件),每条 print 整行即时落盘,而不是 8 KiB 块——块边界会 +# 切断多字节字符,且模拟结束后 runner 空闲等待命令期间最后一块(含每轮 world 决策/提交行) +# 一直留在缓冲区里。 +if hasattr(sys.stdout, 'reconfigure'): + try: + sys.stdout.reconfigure(line_buffering=True) + except (OSError, ValueError): + pass + # XRUN-14: transformers tokenizers 在 fork 后会逐条刷 "The current process just got forked" # 警告(曾把 simulation.log 的整个可见尾部淹没,掩盖真实错误)。必须在任何 huggingface # 相关 import 之前显式禁用 tokenizers 并行才能静音。 @@ -1939,7 +1949,7 @@ def _observe_agent_dynamics(tracker, actual_actions, name_to_id): def _inject_period_context(env, active_ids, round_num, period, timeline, fired_events, world_delta, response_step: bool = False, - language: str = "") -> None: + language: str = "", world_state_brief: str = "") -> None: """CAL-TEMPORAL: env.step 前给每个活跃 agent 追加一条本轮「世界时钟」记忆。 以 USER 角色写入(关键):camel-ai 0.2.78 的 ScoreBasedContextCreator 只保留 @@ -2004,6 +2014,10 @@ def _inject_period_context(env, active_ids, round_num, period, timeline, "## CONFIRMED EVENTS THIS PERIOD", events_block, ] + if str(world_state_brief or "").strip(): + # REFLEXIVE WORLD: the shared WORLD STATE brief (facts, moves, consequences, alliances, + # pending items) every agent observes before acting; never carries scenario shares. + lines.append(str(world_state_brief).strip()) if _flag_true("SIM_WORLD_DELTA", "true"): lines.append("## WHAT CHANGED LAST PERIOD") lines.append(str(world_delta or "").strip() or "(first period)") @@ -3735,6 +3749,872 @@ def _finalize_inband_world_evolution(simulation_dir: str, log_info) -> bool: # ============================================================================ +# ============================================================================ +# REFLEXIVE WORLD MODEL (docs/handoff/reflexive-world-state/design.md §2). +# ---------------------------------------------------------------------------- +# One shared coordinator per simulation directory turns the two independently +# advancing platform loops into a lockstep world: +# begin_round(r) → barrier: waits until world update r-1 is committed, then hands +# out the WORLD STATE brief + WHAT CHANGED digest for round r; +# decide(r) → ONE structured decision per deciding actor per round, shared by +# both platforms (the second platform awaits the same future); +# deliver(r) → when every live platform delivered r (or heartbeat for a dead +# round) the world update runs once (deterministic kernel step + +# bounded arbiter LLM call), the scenario-share WorldState is +# stepped from the actors' scenario commitments, artifacts are +# written and the barrier for r is released. +# Presence-keyed: only when simulation_config carries a ``world_model`` block AND +# SIM_WORLD_MODEL (default true) is on; hours mode / flag off / block absent → the +# legacy in-band evolution path runs byte-identically. Every method is degrade-safe: +# a failure logs, commits what it has and never blocks the round loop beyond +# SIM_WORLD_BARRIER_TIMEOUT_S. +# ============================================================================ +_WORLD_COORDINATORS: Dict[str, "_ReflexiveWorldCoordinator"] = {} +_WORLD_TRAJ_WRITTEN: Dict[str, bool] = {} +WORLD_STATE_FILE = "world_state.json" +WORLD_HISTORY_FILE = "world_state_history.jsonl" +WORLD_ACTIONS_FILE = "world_actions.jsonl" + + +def _world_model_enabled(config: Dict[str, Any]) -> bool: + """World mode is presence-keyed on the sealed ``world_model`` block plus the flag.""" + return (isinstance(config, dict) and isinstance(config.get("world_model"), dict) + and _flag_true("SIM_WORLD_MODEL", "true")) + + +def _append_jsonl(path: str, row: Dict[str, Any]) -> None: + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=False) + "\n") + + +class _ReflexiveWorldCoordinator: + """Shared per-simulation world: barrier, decision step, world update, artifacts.""" + + def __init__(self, config: Dict[str, Any], simulation_dir: str, expected_platforms: int, log_info) -> None: + from app.services import decision_channel as _dc_mod + from app.services.world_model import WorldModel + from app.services.worldstate import WorldState + self._dc = _dc_mod + self._log = log_info + self._dir = simulation_dir + self._config = config + self._expected = max(1, int(expected_platforms or 1)) + self._missing_platform_seq = 0 + tc = config.get("temporal_config") or {} + self._tc = tc if isinstance(tc, dict) else {} + block = config.get("world_model") or {} + self._block = block if isinstance(block, dict) else {} + seed = config.get("world_state_seed") or {} + self._seed = seed if isinstance(seed, dict) else {} + self._scenarios = [str(x) for x in (self._seed.get("scenarios") or self._block.get("scenarios") or []) + if str(x).strip()] + settings = self._block.get("settings") if isinstance(self._block.get("settings"), dict) else {} + self._arbiter_on = _flag_true("SIM_WORLD_ARBITER", "true") and settings.get("arbiter_enabled", True) is not False + try: + self._barrier_timeout = float(_cfg_flag("SIM_WORLD_BARRIER_TIMEOUT_S", "1800") or 1800) + except (TypeError, ValueError): + self._barrier_timeout = 1800.0 + try: + self.decision_max = int(_cfg_flag("SIM_WORLD_DECISION_MAX_ACTORS", "24") or 24) + except (TypeError, ValueError): + self.decision_max = 24 + try: + self._decision_conc = max(1, int(_cfg_flag("SIM_WORLD_DECISION_CONCURRENCY", "6") or 6)) + except (TypeError, ValueError): + self._decision_conc = 6 + self._language = _sim_output_language(config) + self._agent_configs = [c for c in (config.get("agent_configs") or []) if isinstance(c, dict)] + self._power = self._dc._outcome_power_map(self._agent_configs) + # scenario-share WorldState: same knobs/semantics as the in-band evolution + try: + self._inertia = float(os.environ.get("SIM_DECISION_INERTIA", "0.7") or "0.7") + except ValueError: + self._inertia = 0.7 + try: + self._conv_eps = float(os.environ.get("SIM_CONVERGENCE_EPS", "0.02") or "0.02") + except ValueError: + self._conv_eps = 0.02 + self._conv_window = max(1, int(self._dc._cfg("SIM_CONVERGENCE_WINDOW", 3) or 3)) + self._entropy_on = _flag_true("WORLDSTATE_ENTROPY_MIX", "true") + unit = str(self._tc.get("unit") or "").strip() or self._dc._infer_calendar_unit(self._tc.get("round_dates")) + self._unit = unit + self._avg_gap = dict(self._dc._UNIT_NOMINAL_DAYS).get(unit or "", 0.0) + try: + self._n_rounds = int(self._tc.get("n_rounds") or 0) or None + except (TypeError, ValueError): + self._n_rounds = None + self._horizon_date = self._tc.get("horizon_date") or self._seed.get("horizon_date") + self._horizon_source = self._tc.get("horizon_source") or self._seed.get("horizon_source") + self._horizon_defaulted = self._tc.get("horizon_defaulted") + if self._horizon_defaulted is None: + self._horizon_defaulted = self._seed.get("horizon_defaulted") + self._as_of_date = str(self._tc.get("as_of_date") or self._seed.get("as_of_date") or "") or None + self._ws = (WorldState(self._scenarios, self._seed.get("base_rates"), inertia=self._inertia) + if self._scenarios else None) + self._period_by_round: Dict[int, Dict[str, Any]] = {} + for i, rp in enumerate(self._tc.get("round_dates") or []): + if isinstance(rp, dict): + try: + self._period_by_round[int(rp.get("round", i))] = rp + except (TypeError, ValueError): + self._period_by_round[i] = rp + # world model: resume from the last committed state when present, else from the seed + self._world = None + self._resumed_round = 0 # number of loop rounds (0-based count) already committed on disk + self._brief = "" + self._digest = "" + state_path = os.path.join(simulation_dir, WORLD_STATE_FILE) + if os.path.exists(state_path): + try: + with open(state_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + if isinstance(saved, dict) and isinstance(saved.get("world"), dict): + self._world = WorldModel.from_dict(saved["world"]) + self._resumed_round = int(saved.get("committed_round") or 0) + self._brief = str(saved.get("brief") or "") + self._digest = str(saved.get("digest") or "") + self._resumed_committed_round = self._resumed_round + log_info(f"reflexive world: resumed world_state.json at committed round {self._resumed_round}") + except Exception as _load_err: # noqa: BLE001 — a corrupt state file falls back to the seed + self._world = None + log_info(f"reflexive world: could not load world_state.json ({_load_err}); rebuilding from seed") + if self._world is None: + self._world = WorldModel.from_seed(self._block, self._agent_configs, scenarios=self._scenarios) + try: + ratio = settings.get("max_change_ratio", float(_cfg_flag("SIM_WORLD_MAX_CHANGE_RATIO", "0.5") or 0.5)) + except (TypeError, ValueError): + ratio = 0.5 + self._world.settings.setdefault("max_change_ratio", ratio) + self._brief = self._world.render_public_brief(upto_round=0, previous=None) + self._digest = "" + if self._ws is not None and self._world.scenario_shares: + try: + shares = {str(k): float(v) for k, v in (self._world.scenario_shares or {}).items() + if str(k) in set(self._ws.scenarios)} + total = sum(max(0.0, v) for v in shares.values()) + if total > 0: + self._ws.shares = {k: round(max(0.0, shares.get(k, 0.0)) / total, 6) for k in self._ws.scenarios} + except (TypeError, ValueError): + pass + try: + self._world.set_period_labels({i + 1: rp.get("label") for i, rp in self._period_by_round.items() + if isinstance(rp, dict) and rp.get("label")}) + except Exception: # noqa: BLE001 + pass + self._base_shares = dict(self._ws.shares) if self._ws is not None else {} + row0: Dict[str, Any] = {"round": 0, **(self._ws.outcome() if self._ws is not None else {})} + if self._as_of_date: + row0["as_of"] = self._as_of_date + self._trajectory: List[Dict[str, Any]] = [row0] + self._scenario_decisions: List[Dict[str, Any]] = [] + if self._ws is not None and self._resumed_round > 0: + try: + traj_path = os.path.join(simulation_dir, "world_state_trajectory.json") + if os.path.exists(traj_path): + with open(traj_path, "r", encoding="utf-8") as _tf: + saved_traj = json.load(_tf) + rows = saved_traj.get("trajectory") if isinstance(saved_traj, dict) else None + restored: List[Dict[str, Any]] = [] + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + try: + rr = int(row.get("round", -1)) + except (TypeError, ValueError): + continue + if rr < 0 or rr > self._resumed_round: + continue + restored.append(dict(row)) + if restored: + self._trajectory = restored + history = [dict(r.get("shares")) for r in restored if isinstance(r.get("shares"), dict)] + if history: + self._ws.history = history + self._ws.shares = dict(history[-1]) + self._world.scenario_shares = dict(self._ws.shares) + self._ws.round_statuses = [str(r.get("round_status")) for r in restored[1:] + if str(r.get("round_status") or "").strip()] + decisions = saved_traj.get("decisions") if isinstance(saved_traj, dict) else None + self._scenario_decisions = [] + for row in decisions if isinstance(decisions, list) else []: + if not isinstance(row, dict): + continue + try: + rr = int(row.get("round", 0) or 0) + except (TypeError, ValueError): + rr = 0 + if rr > self._resumed_round: + continue + self._scenario_decisions.append(dict(row)) + except Exception as _traj_err: # noqa: BLE001 + log_info(f"reflexive world: could not restore trajectory state ({_traj_err}); continuing from mirrored shares") + self._world_decisions: Dict[int, Dict[int, Any]] = {} + self._decision_futures: Dict[int, "asyncio.Future"] = {} + self._pending: Dict[int, Dict[str, Any]] = {} + self._watermark: Dict[str, int] = {} + self._done: set = set() + self._stalled: set = set() + self._started_rounds: set = set() + self._committed_done: set = set(range(self._resumed_round)) + self._events: Dict[int, "asyncio.Event"] = {} + self._loggers: Dict[str, Any] = {} + self._lock = asyncio.Lock() + self._next_commit = self._resumed_round + self._stepped = 0 + # a resumed run reports the rounds already committed on disk (artifacts stay honest) + self._max_round = int(getattr(self, "_resumed_committed_round", 0) or 0) + self._converged_at: Optional[int] = None + self._stable_streak = 0 + self._prev_date = self._as_of_date + if self._trajectory: + _last = self._trajectory[-1] + _date = _last.get("period_end") or _last.get("as_of") + if _date: + self._prev_date = str(_date) + self._finalized = False + try: + self._seed_value = int(os.environ.get("SIM_SEED", "0") or "0") + except ValueError: + self._seed_value = 0 + try: + from app.utils.llm_client import LLMClient + self._llm = _wrap_llm_client_usage(LLMClient()) + except Exception as _llm_err: # noqa: BLE001 — without an LLM the world still advances mechanically + self._llm = None + log_info(f"reflexive world: LLM unavailable ({_llm_err}); decisions hold, arbiter skipped") + + # ------------------------------------------------------------------ helpers + @property + def world(self): + return self._world + + @property + def scenarios(self) -> List[str]: + return list(self._scenarios) + + def _event_for(self, round_num: int) -> "asyncio.Event": + ev = self._events.get(round_num) + if ev is None: + ev = asyncio.Event() + if round_num in self._committed_done: + ev.set() + self._events[round_num] = ev + return ev + + def _is_committed(self, round_num: int) -> bool: + return round_num in self._committed_done + + def _known_platforms(self) -> set: + return set(self._watermark) | self._done | self._stalled + + def _period_for(self, round_num: int) -> Optional[Dict[str, Any]]: + return self._period_by_round.get(round_num) + + def register_logger(self, platform: str, action_logger: Any) -> None: + if action_logger is not None: + self._loggers[str(platform)] = action_logger + + # ------------------------------------------------------------------ round API + async def begin_round(self, platform: str, round_num: int, period: Optional[Dict[str, Any]], + action_logger: Any = None) -> Tuple[str, str]: + """Barrier on round ``round_num-1`` then hand out this round's brief + digest.""" + self.register_logger(platform, action_logger) + try: + if round_num > 0 and not self._is_committed(round_num - 1): + ev = self._event_for(round_num - 1) + try: + await asyncio.wait_for(ev.wait(), timeout=self._barrier_timeout) + except asyncio.TimeoutError: + missing = [p for p in self._known_platforms() + if p != str(platform) and p not in self._done + and self._watermark.get(p, -1) < round_num - 1] + gap = self._expected - len(self._known_platforms()) + if gap > 0: + placeholders = [] + for _ in range(gap): + self._missing_platform_seq += 1 + placeholders.append(f"missing_platform_{self._missing_platform_seq}") + missing.extend(placeholders) + self._stalled.update(missing) + self._log(f"reflexive world: barrier timeout before round {round_num + 1}; " + f"platform(s) {missing or '?'} marked stalled, committing with available deliveries") + await self._try_commit(flush_upto=round_num - 1) + if round_num not in self._started_rounds: + self._started_rounds.add(round_num) + self._world.start_round(round_num + 1, period or self._period_for(round_num)) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: begin_round failed (continuing with last brief): {_e}") + return self._brief, self._digest + + async def decide(self, platform: str, round_num: int, period: Optional[Dict[str, Any]], + roster_ids: List[int], system_message_lookup, fired_events: List[Dict[str, Any]]) -> Dict[int, Any]: + """One structured decision per roster actor for this round, shared across platforms.""" + if self._is_committed(round_num): + # resume: this round's world update is already on disk (an older checkpoint replays it); + # actors post organically instead of re-deciding a committed period + return {} + fut = self._decision_futures.get(round_num) + if fut is not None: + try: + return dict(await asyncio.wait_for(asyncio.shield(fut), timeout=self._barrier_timeout)) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: waiting for shared decisions of round {round_num + 1} failed: {_e}") + return {} + loop = asyncio.get_running_loop() + fut = loop.create_future() + self._decision_futures[round_num] = fut + result: Dict[int, Any] = {} + try: + result = await self._run_decisions(round_num, period or self._period_for(round_num), roster_ids, + system_message_lookup, fired_events) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: decision step failed in round {round_num + 1} (actors hold): {_e}") + result = {} + self._world_decisions[round_num] = result + if not fut.done(): + fut.set_result(result) + return dict(result) + + async def _run_decisions(self, round_num: int, period: Optional[Dict[str, Any]], roster_ids: List[int], + system_message_lookup, fired_events: List[Dict[str, Any]]) -> Dict[int, Any]: + from app.services.agent_decision import ( + build_decision_prompt, build_option_menu, elicit_decision, hold_decision, + ) + rnd = round_num + 1 + world = self._world + p = period or {} + label = str(p.get("label") or f"round {rnd}") + n_rounds = self._n_rounds or len(self._period_by_round) or rnd + remaining = max(0, int(n_rounds) - rnd) + clock = (f"# WORLD CLOCK — {label} ({p.get('period_start', '?')} → {p.get('period_end', '?')}) | " + f"round {rnd}/{n_rounds} | one {self._unit or 'period'} per round | " + f"forecast horizon {self._horizon_date or '?'} ({remaining} periods remain)") + events_block = "\n".join( + f"- {str(ev.get('content') or '').strip()}" for ev in (fired_events or []) + if isinstance(ev, dict) and str(ev.get("content") or "").strip()) + if events_block: + clock += "\n## CONFIRMED EVENTS THIS PERIOD\n" + events_block + cast_names = [a.name for a in world.actors.values()] + period_end = str(p.get("period_end") or "") or None + sem = asyncio.Semaphore(self._decision_conc) + decisions: Dict[int, Any] = {} + + async def _one(aid: int) -> None: + actor = world.actors.get(aid) + if actor is None: + return + try: + options = build_option_menu(actor, world, rnd) + prompt = build_decision_prompt( + actor, world_clock=clock, public_brief=self._brief, + private_brief=world.render_private_brief(aid, upto_round=rnd - 1), + options=options, scenarios=self._scenarios, cast_names=cast_names, + language=self._language) + try: + system_message = str(system_message_lookup(aid) or "") if system_message_lookup else "" + except Exception: # noqa: BLE001 + system_message = "" + async with sem: + decision = await asyncio.to_thread( + elicit_decision, self._llm, system_message=system_message, user_prompt=prompt, + actor=actor, world=world, options=options, scenarios=self._scenarios, + round_num=rnd, period_end=period_end, language=self._language) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: decision for {actor.name} failed (holds): {_e}") + decision = hold_decision(actor, status="failed", round_num=rnd, period_end=period_end, + problems=[f"decision step error: {type(_e).__name__}: {str(_e)[:200]}"]) + decisions[aid] = decision + + await asyncio.gather(*(_one(int(aid)) for aid in roster_ids)) + try: + for aid in roster_ids: + d = decisions.get(int(aid)) + if d is not None: + _append_jsonl(os.path.join(self._dir, WORLD_ACTIONS_FILE), + {**d.to_dict(), "period_label": label}) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: writing world_actions.jsonl failed: {_e}") + counts: Dict[str, int] = {} + for d in decisions.values(): + key = d.status if d.status != "ok" else ("hold" if d.option.get("id") == "hold_position" else "move") + counts[key] = counts.get(key, 0) + 1 + self._log(f"round {rnd} decisions: {len(decisions)} actors → {counts}") + return decisions + + async def deliver(self, platform: str, round_num: int, period: Optional[Dict[str, Any]], + round_actions: List[Dict[str, Any]], fired_events: List[Dict[str, Any]]) -> None: + """Hand this platform's round to the world; commits once every live platform delivered.""" + try: + if self._is_committed(round_num): + self._watermark[str(platform)] = max(self._watermark.get(str(platform), -1), round_num) + return + buf = self._pending.get(round_num) + if buf is None: + buf = {"period": None, "actions": [], "events": [], "event_keys": set(), "platforms": set()} + self._pending[round_num] = buf + if isinstance(period, dict) and period: + buf["period"] = period + for a in round_actions or []: + if isinstance(a, dict): + buf["actions"].append(a) + for ev in fired_events or []: + if not isinstance(ev, dict): + continue + key = (str(ev.get("date", "") or ""), str(ev.get("content", "") or "")) + if key in buf["event_keys"]: + continue + buf["event_keys"].add(key) + buf["events"].append(ev) + buf["platforms"].add(str(platform)) + self._watermark[str(platform)] = max(self._watermark.get(str(platform), -1), round_num) + await self._try_commit() + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: deliver failed in round {round_num + 1} (isolated): {_e}") + + async def heartbeat(self, platform: str, round_num: int, fired_events: Optional[List[Dict[str, Any]]] = None) -> None: + """Dead / failed round on this platform: advance its watermark without actions.""" + try: + if self._is_committed(round_num): + self._watermark[str(platform)] = max(self._watermark.get(str(platform), -1), round_num) + return + buf = self._pending.get(round_num) + if buf is None: + buf = {"period": self._period_for(round_num), "actions": [], "events": [], "event_keys": set(), + "platforms": set()} + self._pending[round_num] = buf + for ev in fired_events or []: + if not isinstance(ev, dict): + continue + key = (str(ev.get("date", "") or ""), str(ev.get("content", "") or "")) + if key in buf["event_keys"]: + continue + buf["event_keys"].add(key) + buf["events"].append(ev) + self._watermark[str(platform)] = max(self._watermark.get(str(platform), -1), round_num) + await self._try_commit() + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: heartbeat failed in round {round_num + 1} (isolated): {_e}") + + async def platform_done(self, platform: str) -> None: + try: + self._done.add(str(platform)) + await self._try_commit() + if len(self._done | self._stalled) >= self._expected: + await self.finalize() + _WORLD_COORDINATORS.pop(os.path.abspath(self._dir), None) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: platform_done failed (isolated): {_e}") + + async def finalize(self) -> None: + """Flush pending rounds, write the scenario trajectory and the final world state (idempotent).""" + if self._finalized: + return + try: + await self._try_commit(flush_all=True) + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: final flush failed (isolated): {_e}") + self._finalized = True + try: + self._write_world_state() + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: final world_state.json write failed: {_e}") + if self._ws is None or self._stepped <= 0: + return + try: + from app.utils.atomic import write_json_atomic + self._ws.converged_at = self._converged_at + out = self._ws.outcome() + result: Dict[str, Any] = { + "outcome": out, + "trajectory": self._trajectory, + "decisions": self._scenario_decisions, + "converged_at": self._converged_at, + "n_rounds": self._max_round, + "scenarios": self._scenarios, + "schema_version": 3, + "mode": "calendar", + "world_model": self._world_summary(), + } + if self._unit: + result["calendar_unit"] = self._unit + for fk, fv in (("horizon_date", self._horizon_date), ("horizon_source", self._horizon_source), + ("horizon_defaulted", self._horizon_defaulted)): + if fv is not None: + result[fk] = fv + write_json_atomic(os.path.join(self._dir, "world_state_trajectory.json"), result) + with open(os.path.join(self._dir, "decisions.jsonl"), "w", encoding="utf-8") as _df: + for _d in self._scenario_decisions: + _df.write(json.dumps(_d, ensure_ascii=False) + "\n") + _WORLD_TRAJ_WRITTEN[os.path.abspath(self._dir)] = True + self._log(f"reflexive world complete: leader={out.get('leader')} share={out.get('leader_share')} " + f"rounds={self._max_round} arbiter_failed={self._world_summary().get('arbiter_failed_rounds')}") + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: trajectory write failed (isolated): {_e}") + + # ------------------------------------------------------------------ commit + async def _try_commit(self, flush_upto: Optional[int] = None, flush_all: bool = False) -> None: + async with self._lock: + while True: + r = self._next_commit + pending = r in self._pending + if not pending and not flush_all: + break + if flush_all and not self._pending: + break + if flush_all and not pending: + # commit the lowest pending round instead of a hole + r = min(self._pending) + self._next_commit = r + pending = True + forced = flush_all or (flush_upto is not None and r <= flush_upto) + if not forced: + if len(self._known_platforms()) < self._expected: + break + live = [p for p in self._known_platforms() if p not in self._done and p not in self._stalled] + if any(self._watermark.get(p, -1) < r for p in live): + break + buf = self._pending.pop(r, None) or {"period": None, "actions": [], "events": []} + await self._commit_round(r, buf) + self._next_commit = r + 1 + + async def _commit_round(self, round_num: int, buf: Dict[str, Any]) -> None: + from app.services.world_update import run_world_round + from app.services.worldstate import ( + ROUND_STATUS_ABSTAINED, ROUND_STATUS_COMMITTED, ROUND_STATUS_MISSING, ROUND_STATUS_SILENT, + commitments_from_decisions, + ) + rnd = round_num + 1 + period = buf.get("period") if isinstance(buf.get("period"), dict) else self._period_for(round_num) + period_end = str(period.get("period_end")) if period and period.get("period_end") else None + label = str((period or {}).get("label") or f"round {rnd}") + decisions = list((self._world_decisions.get(round_num) or {}).values()) + record: Dict[str, Any] = {} + try: + before = self._world.snapshot() + rng = random.Random(self._seed_value * 1000003 + rnd) + record = await asyncio.to_thread( + run_world_round, self._world, rnd, period, decisions, buf.get("events") or [], + llm=self._llm, rng=rng, scenarios=self._scenarios, arbiter_enabled=self._arbiter_on, + language=self._language) + self._brief = self._world.render_public_brief(upto_round=rnd, previous=before) + self._digest = str(record.get("digest") or "") + except Exception as _e: # noqa: BLE001 — the barrier must still release + self._log(f"reflexive world: update failed in round {rnd} (state frozen, barrier released): {_e}") + try: + self._world.commit_round(rnd) + except Exception: # noqa: BLE001 + pass + record = {"round": rnd, "arbiter_status": "failed", "error": str(_e)[:200], "digest": self._digest, + "decisions": {"total": len(decisions)}, "diff": {}, "headline": f"{label}: update failed"} + # scenario-share WorldState (unchanged downstream contract) + shares_row: Dict[str, Any] = {} + round_status = ROUND_STATUS_MISSING + if self._ws is not None: + try: + rows = [] + abstained = 0 + for d in decisions: + if not getattr(d, "ok", False): + continue + if getattr(d, "scenario", None) == "abstain": + abstained += 1 + continue + row = d.scenario_commitment(self._power.get(d.agent_id)) + if row is not None: + rows.append(row) + conv = commitments_from_decisions(rows) + if conv: + round_status = ROUND_STATUS_COMMITTED + elif abstained: + round_status = ROUND_STATUS_ABSTAINED + elif decisions: + round_status = ROUND_STATUS_SILENT + else: + round_status = ROUND_STATUS_MISSING + eff_inertia = self._dc._inertia_for_gap(self._inertia, self._prev_date, period_end, self._avg_gap) + entropy_days = self._dc._period_days(period) if (self._entropy_on and period) else None + prev_shares = dict(self._ws.shares) + self._ws.step(conv, inertia=eff_inertia, entropy_mix_days=entropy_days, round_status=round_status) + self._world.scenario_shares = dict(self._ws.shares) + out = self._ws.outcome() + leader = out.get("leader") + for row in rows: + self._scenario_decisions.append(dict(row)) + snap: Dict[str, Any] = {"round": rnd, **out, "round_status": round_status} + if period_end: + snap["as_of"] = period_end + if period: + for fk in ("period_start", "period_end", "label"): + if period.get(fk): + snap[fk] = str(period[fk]) + self._trajectory.append(snap) + shares_row = {"shares": dict(self._ws.shares), "leader": leader, + "delta": {k: round(float(self._ws.shares.get(k, 0.0)) - float(prev_shares.get(k, 0.0)), 6) + for k in self._ws.shares}} + _append_jsonl(os.path.join(self._dir, "world_digest.jsonl"), { + "round": rnd, + "period_start": str(period.get("period_start")) if period and period.get("period_start") else None, + "period_end": period_end, + "digest": self._digest, + **shares_row, + }) + if self._ws.converged(self._conv_eps): + self._stable_streak += 1 + if self._converged_at is None and self._stable_streak >= self._conv_window and rnd >= 2: + self._converged_at = rnd + else: + self._stable_streak = 0 + if period_end: + self._prev_date = period_end + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: scenario-share step failed in round {rnd} (isolated): {_e}") + self._stepped += 1 + self._max_round = max(self._max_round, rnd) + self._committed_done.add(round_num) + # artifacts + try: + history_row = { + "round": rnd, "period": period, "period_label": label, + "decisions": record.get("decisions"), + "deterministic": {k: v for k, v in (record.get("deterministic") or {}).items() + if k in ("scheduled_events", "actions", "commitments_lapsed")} | { + "effects_fired": [fx.get("description") for fx in (record.get("deterministic") or {}).get("effects_fired", [])] + }, + "arbiter_status": record.get("arbiter_status"), + "arbiter_problems": record.get("arbiter_problems") or [], + "arbiter": record.get("arbiter") or {}, + "narrative": record.get("narrative") or "", + "diff": record.get("diff") or {}, + "scenario_pressure": record.get("scenario_pressure") or {}, + "scenario_round_status": round_status, + "headline": record.get("headline") or "", + **shares_row, + } + if record.get("error"): + history_row["error"] = record["error"] + _append_jsonl(os.path.join(self._dir, WORLD_HISTORY_FILE), history_row) + self._write_world_state(headline=record.get("headline") or "") + except Exception as _e: # noqa: BLE001 + self._log(f"reflexive world: artifact write failed in round {rnd} (isolated): {_e}") + for plat, logger_obj in list(self._loggers.items()): + try: + log_fn = getattr(logger_obj, "log_world_update", None) + if callable(log_fn): + log_fn(rnd, record.get("headline") or "", period_label=label, + arbiter_status=record.get("arbiter_status"), + decisions=(record.get("decisions") or {}).get("total"), + facts_changed=len(((record.get("diff") or {}).get("variables_changed")) or [])) + except Exception: # noqa: BLE001 + continue + self._event_for(round_num).set() + self._log(f"world update committed: {record.get('headline') or label}") + + def _world_summary(self) -> Dict[str, Any]: + world = self._world + failed = 0 + scored_rounds = 0 + moves_total = 0 + responsive_total = 0 + offers_answered = 0 + try: + hist = os.path.join(self._dir, WORLD_HISTORY_FILE) + if os.path.exists(hist): + with open(hist, "r", encoding="utf-8") as handle: + for line in handle: + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict): + continue + if row.get("arbiter_status") in ("failed", "invalid"): + failed += 1 + beh = row.get("behaviour") if isinstance(row.get("behaviour"), dict) else {} + if beh.get("responsive_share") is not None: + scored_rounds += 1 + moves_total += int(beh.get("moves") or 0) + responsive_total += int(beh.get("responsive_moves") or 0) + offers_answered += int(beh.get("offers_answered") or 0) + except OSError: + failed = 0 + return { + "schema_version": "world-model-summary/v1", + "rounds_committed": self._max_round, + "variables": len(world.variables), + "actors": len(world.actors), + "relationships": len(world.relationships), + "events": len(world.events), + "pending_effects": len(world.pending_effects), + "arbiter_enabled": bool(self._arbiter_on), + "arbiter_failed_rounds": failed, + "decision_actors_max": self.decision_max, + # OFFERS (consent mechanics): how the actors' offers fared + "offers": _offer_counts(world), + # MODELLING GAPS reported by the seed; BEHAVIOUR: textual responsiveness diagnostics + "seed_gaps": len(getattr(world, "seed_gaps", None) or []), + "behaviour": { + "rounds_scored": scored_rounds, + "moves": moves_total, + "responsive_moves": responsive_total, + "responsive_share": round(responsive_total / moves_total, 3) if moves_total else None, + "offers_answered": offers_answered, + }, + } + + def _write_world_state(self, headline: str = "") -> None: + from app.utils.atomic import write_json_atomic + payload = { + "schema_version": "world-state-file/v1", + "simulation_id": str(self._config.get("simulation_id") or ""), + "committed_round": self._max_round, + "brief": self._brief, + "digest": self._digest, + "headline": headline, + "summary": self._world_summary(), + "world": self._world.to_dict(), + } + write_json_atomic(os.path.join(self._dir, WORLD_STATE_FILE), payload) + + +def _get_world_coordinator(config: Dict[str, Any], simulation_dir: str, expected_platforms: int, + log_info) -> Optional[_ReflexiveWorldCoordinator]: + """Shared coordinator per sim dir; None when world mode is off or construction fails.""" + try: + if not _world_model_enabled(config): + return None + key = os.path.abspath(simulation_dir) + coord = _WORLD_COORDINATORS.get(key) + if coord is None: + _WORLD_TRAJ_WRITTEN.pop(key, None) + coord = _ReflexiveWorldCoordinator(config, simulation_dir, int(expected_platforms or 1), log_info) + _WORLD_COORDINATORS[key] = coord + log_info(f"reflexive world model enabled (SIM_WORLD_MODEL, platforms={coord._expected}, " + f"variables={len(coord.world.variables)}, actors={len(coord.world.actors)}, " + f"scenarios={len(coord.scenarios)}, arbiter={'on' if coord._arbiter_on else 'off'})") + return coord + except Exception as _e: # noqa: BLE001 + log_info(f"reflexive world model init failed (falling back to legacy evolution): {_e}") + return None + + +def _offer_counts(world: Any) -> Dict[str, int]: + """Status counts of the world's offers (``world_model.Interaction``), for summaries.""" + counts = {"opened": 0, "open": 0, "accepted": 0, "rejected": 0, "countered": 0, "lapsed": 0, "breached": 0} + for inter in getattr(world, "interactions", {}).values(): + counts["opened"] += 1 + status = str(getattr(inter, "status", "") or "") + if status in ("open", "deferred"): + counts["open"] += 1 + elif status in counts: + counts[status] += 1 + return counts + + +async def _finalize_world_coordinator(simulation_dir: str, log_info) -> bool: + """main() safety net after both platform loops: flush and write artifacts.""" + key = os.path.abspath(simulation_dir) + coord = _WORLD_COORDINATORS.pop(key, None) + if coord is not None: + try: + await coord.finalize() + except Exception as _e: # noqa: BLE001 + log_info(f"reflexive world: finalize failed (isolated): {_e}") + return bool(_WORLD_TRAJ_WRITTEN.get(key)) + + +async def _world_decision_step(coord: _ReflexiveWorldCoordinator, env, db_path: str, config: Dict[str, Any], + active_agents: List[Tuple[int, Any]], round_num: int, period: Optional[Dict[str, Any]], + due_events: List[Dict[str, Any]], agent_names: Dict[int, str], action_logger, + last_rowid: int, language: str, platform: str, log_info + ) -> Tuple[Dict[int, Any], int, int]: + """Run (or join) the shared decision step and publish statements on this platform. + + Returns ``(decisions, last_rowid, posted)``. Never raises. + """ + try: + from app.services.agent_decision import deciding_roster + active_map = {int(aid): agent for aid, agent in active_agents} + roster = [aid for aid in deciding_roster(config.get("agent_configs") or [], list(active_map), + max_actors=coord.decision_max) if aid in active_map] + + def _lookup(aid: int) -> str: + agent = active_map.get(aid) + if agent is None: + agent = env.agent_graph.get_agent(aid) + msg = getattr(agent, "system_message", None) or getattr(agent, "_system_message", None) + return str(getattr(msg, "content", "") or "") + + decisions = await coord.decide(platform, round_num, period, roster, _lookup, due_events) + posted, last_rowid = await _publish_world_decisions( + env, db_path, decisions, active_map, round_num, str((period or {}).get("label") or ""), + agent_names, action_logger, last_rowid, log_info) + return decisions, last_rowid, posted + except Exception as _e: # noqa: BLE001 + log_info(f"reflexive world: decision step failed on {platform} (agents post organically): {_e}") + return {}, last_rowid, 0 + + +async def _publish_world_decisions(env, db_path: str, decisions: Dict[int, Any], active_map: Dict[int, Any], + round_num: int, period_label: str, agent_names: Dict[int, str], action_logger, + last_rowid: int, log_info) -> Tuple[int, int]: + """Publish each ok, non-hold decision as a CREATE_POST and write private memory notes.""" + from app.services.agent_decision import HOLD_OPTION_ID, decision_memory_note + actions: Dict[Any, Any] = {} + rows: List[Tuple[int, Any]] = [] + for aid, d in decisions.items(): + if not getattr(d, "ok", False) or d.option.get("id") == HOLD_OPTION_ID or not d.public_statement: + continue + agent = active_map.get(int(aid)) + if agent is None: + try: + agent = env.agent_graph.get_agent(int(aid)) + except Exception: # noqa: BLE001 + continue + actions[agent] = ManualAction(action_type=ActionType.CREATE_POST, + action_args={"content": d.public_statement}) + rows.append((int(aid), d)) + try: + from camel.messages import BaseMessage + from camel.types import OpenAIBackendRole + for aid, d in decisions.items(): + note = decision_memory_note(d, period_label) + agent = active_map.get(int(aid)) + if not note or agent is None or not hasattr(agent, "update_memory"): + continue + try: + agent.update_memory(BaseMessage.make_user_message(role_name="WorldDecision", content=note), + OpenAIBackendRole.USER) + except Exception: # noqa: BLE001 + continue + except Exception: # noqa: BLE001 — camel absent in some test harnesses + pass + if not actions: + return 0, last_rowid + try: + await env.step(actions) + except Exception as _e: # noqa: BLE001 + log_info(f"reflexive world: publishing {len(actions)} decision posts failed (continuing): {_e}") + return 0, last_rowid + posted = 0 + for aid, d in rows: + if action_logger: + try: + action_logger.log_action( + round_num=round_num + 1, agent_id=aid, + agent_name=(agent_names or {}).get(aid, f"Agent_{aid}"), + action_type="CREATE_POST", + action_args={"content": d.public_statement, "is_world_decision": True, + "option_id": d.option.get("id"), "action_kind": d.option.get("kind"), + "targets": list(d.targets), "magnitude": round(d.magnitude, 3)}) + except Exception: # noqa: BLE001 + pass + posted += 1 + # the statements are already logged with their decision tags: drop their trace rows + _, last_rowid = fetch_new_actions_from_db(db_path, last_rowid, agent_names) + log_info(f"round {round_num + 1}: published {posted} decision statements") + return posted, last_rowid + + # RUN-7: 轮级检查点与断点续跑(默认关,SIM_RESUME=true 开启)。 # 每轮 ~32 次 LLM 调用,崩溃/被杀/后端重启曾意味着 100% 花费作废并从第 0 轮重烧。 # 检查点只记「已完成轮次 + trace 游标 + 累计动作数」;续跑保留模拟 DB——OASIS 的 @@ -4986,8 +5866,14 @@ def log_info(msg): # CAL-TEMPORAL: in-band 世界演化器(SIM_DECISION_CHANNEL_INBAND,默认开,仅日历模式; # world_state_seed.scenarios 为空 → None,静默关闭)。双平台共享同一实例(按 sim 目录 # 注册,expected=semaphore_platforms),凑齐两平台本轮水位后才按轮序步进一次。 + # REFLEXIVE WORLD (design §2): shared coordinator owns the barrier, the decision step, the + # world update and the scenario-share stepping; when it is active the legacy in-band + # evolution stays off so the WorldState is stepped exactly once per round. + _world_coord = (_get_world_coordinator(config, simulation_dir, semaphore_platforms, log_info) + if calendar else None) _inband_evo = (_get_inband_evolution(config, simulation_dir, semaphore_platforms, log_info) - if calendar else None) + if (calendar and _world_coord is None) else None) + _wc_brief = "" def _write_ckpt(completed_round: int) -> None: # 闭包按调用时读取 last_rowid / total_actions 的当前值;ITEM 3: 附带 config_hash 与实时 RNG 状态 @@ -5068,6 +5954,9 @@ def _write_ckpt(completed_round: int) -> None: round_num + 1, 0, simulated_hours=round((round_num + 1) * minutes_per_round / 60, 2), **_supported_log_kwargs(action_logger, "log_round_end", _cal_extra)) + if _world_coord is not None: + # REFLEXIVE WORLD: a dead round still advances the world (due effects, events) + await _world_coord.heartbeat(_ckpt_platform, round_num, _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: # CAL-TEMPORAL: 死轮只推演化水位(无动作交付),防止双平台按轮配对停摆 _inband_evo.heartbeat(_ckpt_platform, round_num) @@ -5075,6 +5964,13 @@ def _write_ckpt(completed_round: int) -> None: _write_ckpt(round_num + 1) # RUN-7: 死轮也推进检查点 continue + # REFLEXIVE WORLD: barrier on last round's world update → this round's WORLD STATE brief + # and WHAT CHANGED digest (both platforms observe the same committed world). + if _world_coord is not None: + _wc_brief, _wc_digest = await _world_coord.begin_round( + _ckpt_platform, round_num, _period, action_logger) + world_delta_text = _wc_digest + # I-2-1: 注入本轮动态情感状态到各活跃 agent 的系统提示(默认关 → no-op) _inject_agent_dynamics(active_agents, dynamics_tracker, log_info) @@ -5089,17 +5985,32 @@ def _write_ckpt(completed_round: int) -> None: world_delta_text, response_step=_reaction_on, language=_sim_language, + world_state_brief=_wc_brief, ) except Exception as _pc_err: # noqa: BLE001 log_info(f"世界时钟注入失败,跳过(不中断模拟): {_pc_err}") if _reaction_on: _advance_reaction_window(_reaction_state, db_path) - actions = {agent: LLMAction() for _, agent in active_agents} + # REFLEXIVE WORLD: deciding actors make ONE structured move (published as a CREATE_POST + # ManualAction with decision tags); actors whose decision failed / was invalid / carried a + # statement in the wrong language fall back to the organic LLMAction post so a dead + # decision step never silences the cast. + _wc_decisions: Dict[int, Any] = {} + _wc_posted = 0 + if _world_coord is not None: + _wc_decisions, last_rowid, _wc_posted = await _world_decision_step( + _world_coord, result.env, db_path, config, active_agents, round_num, _period, + _scheduled_events_due(event_config, round_num), agent_names, action_logger, + last_rowid, _sim_language, _ckpt_platform, log_info) + total_actions += _wc_posted + actions = {agent: LLMAction() for aid, agent in active_agents + if not (aid in _wc_decisions and getattr(_wc_decisions[aid], 'ok', False))} # 健壮性:单次 env.step 内的某个 agent LLM 调用失败(超时/降级/异常)不应中断整场模拟。 # 记录并跳过本轮,让模拟继续,保住此前所有轮次的进度。 try: - await result.env.step(actions) + if actions: + await result.env.step(actions) consec_step_failures = 0 # XRUN-4: 成功轮清零连续失败计数 last_step_err_cls = None except Exception as step_err: @@ -5115,6 +6026,9 @@ def _write_ckpt(completed_round: int) -> None: round_num + 1, 0, simulated_hours=round((round_num + 1) * minutes_per_round / 60, 2), **_supported_log_kwargs(action_logger, "log_round_end", _cal_extra)) + if _world_coord is not None: + # REFLEXIVE WORLD: the decisions already made still shape the world this round + await _world_coord.heartbeat(_ckpt_platform, round_num, _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: # CAL-TEMPORAL: 失败轮同样只推演化水位(该轮已按 0 动作记账) _inband_evo.heartbeat(_ckpt_platform, round_num) @@ -5132,7 +6046,7 @@ def _write_ckpt(completed_round: int) -> None: db_path, last_rowid, agent_names ) - round_action_count = 0 + round_action_count = _wc_posted for action_data in actual_actions: if action_logger: action_logger.log_action( @@ -5176,6 +6090,11 @@ def _write_ckpt(completed_round: int) -> None: # CAL-TEMPORAL in-band 世界演化(spec §4):有机动作 + 参与度注入落账后,交付本轮 # 动作/到期事件给共享 WorldState;产出的定性摘要喂下一轮 WORLD CLOCK 头。deliver # 内部全隔离——任何失败 → 告警 + 下一轮空摘要,绝不中断轮循环。 + if _world_coord is not None: + # REFLEXIVE WORLD: hand this platform's round to the shared world; the update runs + # once every live platform delivered and the next begin_round observes it. + await _world_coord.deliver(_ckpt_platform, round_num, _period, actual_actions, + _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: _inband_evo.deliver(_ckpt_platform, round_num, _period, actual_actions, _scheduled_events_due(event_config, round_num)) @@ -5196,6 +6115,8 @@ def _write_ckpt(completed_round: int) -> None: # CAL-TEMPORAL: 本平台回路结束 → 通知 in-band 演化;全部平台完成时冲刷剩余轮并落 # world_state_trajectory.json(schema v3)+ decisions.jsonl(main() 另有兜底收尾)。 + if _world_coord is not None: + await _world_coord.platform_done(_ckpt_platform) if _inband_evo is not None: _inband_evo.platform_done(_ckpt_platform) @@ -5537,8 +6458,14 @@ def log_info(msg): # CAL-TEMPORAL: in-band 世界演化器(SIM_DECISION_CHANNEL_INBAND,默认开,仅日历模式; # world_state_seed.scenarios 为空 → None,静默关闭)。双平台共享同一实例(按 sim 目录 # 注册,expected=semaphore_platforms),凑齐两平台本轮水位后才按轮序步进一次。 + # REFLEXIVE WORLD (design §2): shared coordinator owns the barrier, the decision step, the + # world update and the scenario-share stepping; when it is active the legacy in-band + # evolution stays off so the WorldState is stepped exactly once per round. + _world_coord = (_get_world_coordinator(config, simulation_dir, semaphore_platforms, log_info) + if calendar else None) _inband_evo = (_get_inband_evolution(config, simulation_dir, semaphore_platforms, log_info) - if calendar else None) + if (calendar and _world_coord is None) else None) + _wc_brief = "" def _write_ckpt(completed_round: int) -> None: # 闭包按调用时读取 last_rowid / total_actions 的当前值;ITEM 3: 附带 config_hash 与实时 RNG 状态 @@ -5619,6 +6546,9 @@ def _write_ckpt(completed_round: int) -> None: round_num + 1, 0, simulated_hours=round((round_num + 1) * minutes_per_round / 60, 2), **_supported_log_kwargs(action_logger, "log_round_end", _cal_extra)) + if _world_coord is not None: + # REFLEXIVE WORLD: a dead round still advances the world (due effects, events) + await _world_coord.heartbeat(_ckpt_platform, round_num, _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: # CAL-TEMPORAL: 死轮只推演化水位(无动作交付),防止双平台按轮配对停摆 _inband_evo.heartbeat(_ckpt_platform, round_num) @@ -5626,6 +6556,13 @@ def _write_ckpt(completed_round: int) -> None: _write_ckpt(round_num + 1) # RUN-7: 死轮也推进检查点 continue + # REFLEXIVE WORLD: barrier on last round's world update → this round's WORLD STATE brief + # and WHAT CHANGED digest (both platforms observe the same committed world). + if _world_coord is not None: + _wc_brief, _wc_digest = await _world_coord.begin_round( + _ckpt_platform, round_num, _period, action_logger) + world_delta_text = _wc_digest + # I-2-1: 注入本轮动态情感状态到各活跃 agent 的系统提示(默认关 → no-op) _inject_agent_dynamics(active_agents, dynamics_tracker, log_info) @@ -5640,17 +6577,32 @@ def _write_ckpt(completed_round: int) -> None: world_delta_text, response_step=_reaction_on, language=_sim_language, + world_state_brief=_wc_brief, ) except Exception as _pc_err: # noqa: BLE001 log_info(f"世界时钟注入失败,跳过(不中断模拟): {_pc_err}") if _reaction_on: _advance_reaction_window(_reaction_state, db_path) - actions = {agent: LLMAction() for _, agent in active_agents} + # REFLEXIVE WORLD: deciding actors make ONE structured move (published as a CREATE_POST + # ManualAction with decision tags); actors whose decision failed / was invalid / carried a + # statement in the wrong language fall back to the organic LLMAction post so a dead + # decision step never silences the cast. + _wc_decisions: Dict[int, Any] = {} + _wc_posted = 0 + if _world_coord is not None: + _wc_decisions, last_rowid, _wc_posted = await _world_decision_step( + _world_coord, result.env, db_path, config, active_agents, round_num, _period, + _scheduled_events_due(event_config, round_num), agent_names, action_logger, + last_rowid, _sim_language, _ckpt_platform, log_info) + total_actions += _wc_posted + actions = {agent: LLMAction() for aid, agent in active_agents + if not (aid in _wc_decisions and getattr(_wc_decisions[aid], 'ok', False))} # 健壮性:单次 env.step 内的某个 agent LLM 调用失败(超时/降级/异常)不应中断整场模拟。 # 记录并跳过本轮,让模拟继续,保住此前所有轮次的进度。 try: - await result.env.step(actions) + if actions: + await result.env.step(actions) consec_step_failures = 0 # XRUN-4: 成功轮清零连续失败计数 last_step_err_cls = None except Exception as step_err: @@ -5666,6 +6618,9 @@ def _write_ckpt(completed_round: int) -> None: round_num + 1, 0, simulated_hours=round((round_num + 1) * minutes_per_round / 60, 2), **_supported_log_kwargs(action_logger, "log_round_end", _cal_extra)) + if _world_coord is not None: + # REFLEXIVE WORLD: the decisions already made still shape the world this round + await _world_coord.heartbeat(_ckpt_platform, round_num, _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: # CAL-TEMPORAL: 失败轮同样只推演化水位(该轮已按 0 动作记账) _inband_evo.heartbeat(_ckpt_platform, round_num) @@ -5683,7 +6638,7 @@ def _write_ckpt(completed_round: int) -> None: db_path, last_rowid, agent_names ) - round_action_count = 0 + round_action_count = _wc_posted for action_data in actual_actions: if action_logger: action_logger.log_action( @@ -5727,6 +6682,11 @@ def _write_ckpt(completed_round: int) -> None: # CAL-TEMPORAL in-band 世界演化(spec §4):有机动作 + 参与度注入落账后,交付本轮 # 动作/到期事件给共享 WorldState;产出的定性摘要喂下一轮 WORLD CLOCK 头。deliver # 内部全隔离——任何失败 → 告警 + 下一轮空摘要,绝不中断轮循环。 + if _world_coord is not None: + # REFLEXIVE WORLD: hand this platform's round to the shared world; the update runs + # once every live platform delivered and the next begin_round observes it. + await _world_coord.deliver(_ckpt_platform, round_num, _period, actual_actions, + _scheduled_events_due(event_config, round_num)) if _inband_evo is not None: _inband_evo.deliver(_ckpt_platform, round_num, _period, actual_actions, _scheduled_events_due(event_config, round_num)) @@ -5747,6 +6707,8 @@ def _write_ckpt(completed_round: int) -> None: # CAL-TEMPORAL: 本平台回路结束 → 通知 in-band 演化;全部平台完成时冲刷剩余轮并落 # world_state_trajectory.json(schema v3)+ decisions.jsonl(main() 另有兜底收尾)。 + if _world_coord is not None: + await _world_coord.platform_done(_ckpt_platform) if _inband_evo is not None: _inband_evo.platform_done(_ckpt_platform) @@ -5973,6 +6935,10 @@ async def main(): # 强制冲刷滞留轮并落 world_state_trajectory.json(schema v3)。返回值 = 本次运行 in-band # 是否已写出轨迹(True 时下方 post-hoc 决策通道跳过,避免覆盖轮内演化结果;spec §4)。 _inband_traj_written = _finalize_inband_world_evolution(simulation_dir, log_manager.info) + # REFLEXIVE WORLD: same safety net for the shared coordinator (flush + artifacts); when it + # wrote the trajectory the post-hoc decision channel must not overwrite it. + if await _finalize_world_coordinator(simulation_dir, log_manager.info): + _inband_traj_written = True # NEXTSTEPS P1-1/P1-2/P1-4: 模拟结束后演化"结果世界态"——按轮 elicit 各活跃 agent 的承诺 # (朝哪个 forecast 情景),资源加权步进 WorldState,落 world_state_trajectory.json + diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d9a07dc2..644a9bed 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,6 +5,7 @@ burning API calls (I-7-4: record/replay-style stub). """ +import json import os import sys @@ -83,6 +84,81 @@ def supports_native_tools(self): return False +def content_filter_error(role="user"): + """A provider content-moderation rejection shaped like the live Zhipu / GLM one + (``sim_d6ebcc77e22d``: HTTP 400, provider code 1301): the real ``openai.BadRequestError`` + when the SDK is importable, else a duck-typed stub with the same ``status_code`` / + ``body`` / message so ``app.utils.llm_errors`` sees identical attributes.""" + message = ("Error code: 400 - {'contentFilter': [{'level': 1, 'role': '" + role + "'}], " + "'error': {'code': '1301', 'message': '系统检测到输入或生成内容可能包含不安全或敏感内容," + "请您避免输入易产生敏感内容的提示语,感谢您的配合。'}}") + body = {"contentFilter": [{"level": 1, "role": role}], + "error": {"code": "1301", "message": "系统检测到输入或生成内容可能包含不安全或敏感内容"}} + try: + import httpx + import openai + response = httpx.Response(400, request=httpx.Request("POST", "http://provider.test/v1/chat/completions")) + return openai.BadRequestError(message, response=response, body=body) + except Exception: # noqa: BLE001 — minimal environments without the SDK + class BadRequestError(Exception): + status_code = 400 + + exc = BadRequestError(message) + exc.body = body + return exc + + +@pytest.fixture +def scripted_failover(monkeypatch): + """Factory for a real ``LLMClient`` with scripted, offline transports: the primary raises + ``primary_error``; the configured fallback provider (the S9 failover inside + ``LLMClient.chat``) answers ``fallback_response`` or raises ``fallback_error``. + ``fallback_provider=None`` leaves the fallback unconfigured. Returns + ``(client, transports)``; ``transports`` records ``(provider, is_fallback)`` per attempt + so tests can pin "one primary attempt, exactly one fallback retry".""" + import app.utils.llm_client as lc + + def _reset(): + lc._FB_AUTH_UNAVAILABLE_UNTIL.clear() + lc._FB_OPENAI_CLIENTS.clear() + lc._CB_STATE.clear() + getattr(lc, "_FB_MISCONFIG_WARNED", set()).clear() + + _reset() + + def _make(primary_error, fallback_response=None, *, fallback_error=None, + primary_provider="glm", fallback_provider="kimi"): + if fallback_provider: + monkeypatch.setenv("LLM_FALLBACK_PROVIDER", fallback_provider) + monkeypatch.setenv("LLM_FALLBACK_MODEL", "fallback-model") + monkeypatch.setenv("LLM_FALLBACK_API_KEY", "sk-fallback") + monkeypatch.setenv("LLM_FALLBACK_BASE_URL", "http://127.0.0.1:1/v1") + else: + for env in ("LLM_FALLBACK_PROVIDER", "LLM_FALLBACK_MODEL", "LLM_FALLBACK_API_KEY", + "LLM_FALLBACK_BASE_URL"): + monkeypatch.delenv(env, raising=False) + monkeypatch.setattr(lc.Config, "LLM_CACHE_ENABLED", False, raising=False) + monkeypatch.setattr(lc.Config, "LLM_TELEMETRY_ENABLED", False, raising=False) + monkeypatch.setattr(lc, "_retry_delay", lambda exc, attempt: 0.0) + transports = [] + + def _transport(self, messages, temperature, max_tokens, response_format=None, tier="strong"): + transports.append((self.provider, bool(self._is_fallback))) + if not self._is_fallback: + raise primary_error + if fallback_error is not None: + raise fallback_error + return fallback_response if isinstance(fallback_response, str) else json.dumps(fallback_response) + + monkeypatch.setattr(lc.LLMClient, "_chat_openai", _transport) + client = lc.LLMClient(provider=primary_provider, api_key="sk-primary", + base_url="http://127.0.0.1:1/v1", model="primary-model") + return client, transports + + yield _make + _reset() + + @pytest.fixture(autouse=True) def _no_prediction_market_network(monkeypatch): """预测市场客户端离线化(offline-first 契约):Polymarket 公开 API 是 keyless 的, diff --git a/backend/tests/test_agent_decision.py b/backend/tests/test_agent_decision.py new file mode 100644 index 00000000..fd6eb810 --- /dev/null +++ b/backend/tests/test_agent_decision.py @@ -0,0 +1,402 @@ +"""Structured actor decisions (design §2.4): agency classes, option menus, parsing. + +Offline: the only LLM touch is the conftest FakeLLMClient (and a scripted, offline +LLMClient for the fallback-provider retry). Pins the typed degradation +(``invalid`` / ``failed`` / ``language_mismatch`` → hold_position), target/scenario +validation, the run-language script check on public statements, precise provider +failure records, menu priorities and the roster rule (principals first, audience out). +""" + +import pytest + +from app.services import agent_decision as ad +from app.services.world_model import WorldModel +from tests.conftest import FakeLLMClient, content_filter_error + + +def _world(): + seed = { + "question": "Will the permit unlock deployment?", + "scenarios": ["Permit granted", "Permit delayed"], + "state_variables": [{"name": "Regional capacity", "value": 120, "unit": "MW"}], + "actors": [ + {"agent_id": 0, "name": "Alpha Systems", "agency_class": "firm", "aliases": ["Alpha"], + "documented_plans": ["Invest $2 billion in the Aurora line", "File a permit response"], + "goals": ["Shape the permit timetable"], "incentives": {"gains_if": "timetable holds"}}, + {"agent_id": 1, "name": "Beta Union", "agency_class": "labor_union"}, + {"agent_id": 2, "name": "Grid Regulator", "agency_class": "regulator_agency"}, + ], + } + cfgs = [{"agent_id": i, "entity_name": n} for i, n in enumerate(["Alpha Systems", "Beta Union", "Grid Regulator"])] + world = WorldModel.from_seed(seed, cfgs) + world.start_round(1, {"label": "2026-Q3", "period_start": "2026-07-02", "period_end": "2026-09-30"}) + return world + + +# ----------------------------------------------------------------------------- agency classes +@pytest.mark.parametrize("type_name, role, expected", [ + ("Government", "Ministry of Commerce", "sovereign_government"), + ("Organization", "Tesla Inc, EV maker", "firm"), + ("Person", "Senator for Ohio", "legislature"), + ("Person", "Fed Chair Jerome Powell", "central_bank"), + ("MediaOutlet", "Reuters", "media_outlet"), + ("Organization", "United Auto Workers union", "labor_union"), + ("Organization", "European Commission", "regulator_agency"), + ("Organization", "NATO", "international_org"), + ("Organization", "Greenpeace", "ngo_advocacy"), + ("Organization", "SEMI industry association", "industry_coalition"), + ("Person", "Elon Musk, CEO", "individual_leader"), + ("Collective", "EV buyers", "public_collective"), + ("Organization", "", "firm"), + ("Thing", "", "other_actor"), + ("firm", "", "firm"), +]) +def test_infer_agency_class(type_name, role, expected): + assert ad.infer_agency_class(type_name, role) == expected + + +def test_infer_agency_class_word_boundaries_and_archetype(): + # "senator" must not be classified via the "nato" substring + assert ad.infer_agency_class("Person", "Senator") != "international_org" + assert ad.infer_agency_class("", "", archetype="collective") == "public_collective" + assert ad.infer_agency_class(None, None) == "other_actor" + assert ad.default_capacity("sovereign_government") == 3 + assert ad.default_capacity("unknown") == ad.DEFAULT_CAPACITY_BY_CLASS["other_actor"] + + +def test_catalog_covers_every_class_with_hold_available(): + for klass in ad.AGENCY_CLASSES: + templates = ad.catalog_for(klass) + assert templates, klass + assert all(t.kind in ad.ACTION_KINDS for t in templates) + ids = [t.id for t in templates] + assert len(ids) == len(set(ids)), klass + + +# ----------------------------------------------------------------------------- option menu +def test_option_menu_priorities_and_capacity_filter(): + world = _world() + alpha = world.actors[0] + menu = ad.build_option_menu(alpha, world, 1) + ids = [o["id"] for o in menu] + assert ids[-1] == ad.HOLD_OPTION_ID + assert ids[0] == "plan_1" and ids[1] == "plan_2" # documented plans come first (no responses yet) + assert menu[0]["kind"] == "economic" and menu[0]["lag"] == 2 # "invest" classified as economic + assert menu[1]["kind"] == "legal" + assert len(menu) <= ad.MAX_OPTIONS + # capacity exhausted → only zero-cost moves (hold) remain affordable + alpha.capacity_used = alpha.capacity_per_round + menu2 = ad.build_option_menu(alpha, world, 1) + assert all(o["cost"] <= 0 or o["id"].startswith("plan_") for o in menu2) + assert menu2[-1]["id"] == ad.HOLD_OPTION_ID + + +def test_option_menu_adds_response_options_to_moves_aimed_at_actor(): + world = _world() + world.register_decisions(1, [{ + "agent_id": 1, "actor_name": "Beta Union", + "option": {"id": "strike_action", "label": "Call a strike", "kind": "coercive", "cost": 3}, + "targets": ["Alpha Systems"], "magnitude": 0.9, "status": "ok", + }]) + world.commit_round(1) + world.start_round(2, {"label": "2026-Q4"}) + menu = ad.build_option_menu(world.actors[0], world, 2) + assert menu[0]["id"] == "respond_1_counter" and "Beta Union" in menu[0]["label"] + assert menu[0]["suggested_targets"] == ["Beta Union"] + assert menu[1]["id"] == "respond_1_negotiate" + + +def test_primary_catalog_actions_are_affordable_by_default_capacity(): + checks = [ + ("firm", "invest_capex"), + ("central_bank", "rate_move"), + ("labor_union", "strike_action"), + ("regulator_agency", "issue_ruling"), + ] + for klass, option_id in checks: + tmpl = next(t for t in ad.catalog_for(klass) if t.id == option_id) + assert tmpl.cost <= ad.default_capacity(klass) + + +def test_irreversible_actions_are_not_offered_again_after_intervening_hold(): + world = _world() + alpha = world.actors[0] + world.register_decisions(1, [{ + "agent_id": 0, "actor_name": "Alpha Systems", + "option": {"id": "product_launch", "label": "Launch", "kind": "operational", "cost": 2, "reversibility": "irreversible"}, + "targets": [], "magnitude": 0.9, "status": "ok", + }]) + world.commit_round(1) + world.start_round(2, {"label": "2026-Q4"}) + world.register_decisions(2, [{ + "agent_id": 0, "actor_name": "Alpha Systems", + "option": {"id": ad.HOLD_OPTION_ID, "label": "Hold", "kind": "wait", "cost": 0}, + "targets": [], "magnitude": 0.0, "status": "ok", + }]) + world.commit_round(2) + world.start_round(3, {"label": "2027-Q1"}) + ids = [o["id"] for o in ad.build_option_menu(alpha, world, 3)] + assert "product_launch" not in ids + + +def test_deciding_roster_principals_first_audience_excluded_capped(): + cfgs = [ + {"agent_id": 0, "entity_name": "A", "cadence": "sampled", "influence_weight": 0.9}, + {"agent_id": 1, "entity_name": "B", "cadence": "principal", "influence_weight": 0.5}, + {"agent_id": 2, "entity_name": "C", "cadence": "principal", "influence_weight": 0.8}, + {"agent_id": 3, "entity_name": "Aud", "entity_type": "Audience", "influence_weight": 1.0}, + {"agent_id": 4, "entity_name": "D", "influence_weight": 0.2}, + ] + assert ad.deciding_roster(cfgs, active_ids=[0, 3, 4]) == [2, 1, 0, 4] + assert ad.deciding_roster(cfgs, active_ids=[0, 4], max_actors=3) == [2, 1, 0] + assert ad.deciding_roster(cfgs, active_ids=[]) == [2, 1] # principals decide even when not sampled + + +# ----------------------------------------------------------------------------- prompt +def test_decision_prompt_carries_briefs_options_scenarios_and_contract(): + world = _world() + alpha = world.actors[0] + menu = ad.build_option_menu(alpha, world, 1) + prompt = ad.build_decision_prompt(alpha, world_clock="# WORLD CLOCK — 2026-Q3", public_brief="# WORLD STATE\n- x", + private_brief="# YOUR SITUATION — Alpha Systems", options=menu, + scenarios=world.scenarios, cast_names=["Alpha Systems", "Beta Union"], + language="English") + assert prompt.startswith("# WORLD CLOCK — 2026-Q3") + assert "# WORLD STATE" in prompt and "# YOUR SITUATION" in prompt + assert "[plan_1]" in prompt and f"[{ad.HOLD_OPTION_ID}]" in prompt + assert "Candidate scenarios: Permit granted; Permit delayed" in prompt + assert "Your documented goals: Shape the permit timetable" in prompt + assert "you gain if: timetable holds" in prompt + assert "Write public_statement in English." in prompt + assert '"scenario_commitment"' in prompt and "Return ONLY JSON" in prompt + + +# ----------------------------------------------------------------------------- parsing +def _menu(world): + return ad.build_option_menu(world.actors[0], world, 1) + + +def test_parse_valid_decision_resolves_targets_and_scenario(): + world = _world() + menu = _menu(world) + raw = { + "option_id": "plan_1", "targets": ["beta union", "Nobody Known", "Alpha Systems"], "magnitude": 1.7, + "public_statement": "We commit $2bn to Aurora now.", "private_rationale": "lock the timetable", + "commitment": {"description": "Break ground on Aurora", "due_periods": 2}, + "alliance_moves": [{"target": "Grid Regulator", "move": "propose"}, {"target": "Alpha Systems", "move": "break"}], + "expected_reactions": [{"actor": "Beta Union", "reaction": "strike threat"}], + "scenario_commitment": {"scenario": "permit granted", "confidence": 0.8}, + } + d = ad.parse_decision(raw, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1, period_end="2026-09-30") + assert d.ok and d.option["id"] == "plan_1" + assert d.targets == ["Beta Union"] # unknown and self dropped + assert d.magnitude == 1.0 + assert d.commitment == {"description": "Break ground on Aurora", "due_periods": 2} + assert d.alliance_moves == [{"target": "Grid Regulator", "move": "propose"}] + assert d.scenario == "Permit granted" and d.scenario_confidence == 0.8 + row = d.scenario_commitment(outcome_power=1.5) + assert row["scenario"] == "Permit granted" and row["outcome_power"] == 1.5 and row["period_end"] == "2026-09-30" + assert d.to_dict()["schema_version"] == ad.DECISION_CONTRACT_VERSION + + +def test_parse_preserves_explicit_zero_scenario_confidence(): + world = _world() + menu = _menu(world) + d = ad.parse_decision({ + "option_id": "plan_1", + "public_statement": "Proceed.", + "scenario_commitment": {"scenario": "Permit delayed", "confidence": 0}, + }, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, round_num=1) + row = d.scenario_commitment(outcome_power=1.0) + assert row is not None and row["confidence"] == 0.0 + + +def test_parse_unknown_option_and_bad_payload_degrade_to_typed_hold(): + world = _world() + menu = _menu(world) + bad = ad.parse_decision({"option_id": "nuke_everything", "public_statement": "x"}, actor=world.actors[0], + world=world, options=menu, scenarios=world.scenarios, round_num=1) + assert bad.status == "invalid" and bad.option["id"] == ad.HOLD_OPTION_ID and bad.raw_option_id == "nuke_everything" + assert bad.scenario_commitment() is None + assert ad.parse_decision("not json", actor=world.actors[0], world=world, options=menu, + scenarios=world.scenarios, round_num=1).status == "invalid" + assert ad.parse_decision(None, actor=world.actors[0], world=world, options=menu, + scenarios=world.scenarios, round_num=1).status == "invalid" + + +def test_parse_custom_option_requires_catalogued_kind(): + world = _world() + menu = _menu(world) + ok = ad.parse_decision({"option_id": "custom", "custom_action": {"kind": "legal", "label": "File an injunction"}, + "targets": ["Grid Regulator"], "public_statement": "We are filing."}, + actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, round_num=1) + assert ok.ok and ok.option["id"] == "custom" and ok.option["kind"] == "legal" and ok.option["lag"] == 1 + bad = ad.parse_decision({"option_id": "custom", "custom_action": {"kind": "magic", "label": "x"}}, + actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, round_num=1) + assert bad.status == "invalid" + + +def test_parse_tolerates_label_echo_and_fills_statement(): + world = _world() + menu = _menu(world) + d = ad.parse_decision({"option_id": "Hold position: wait", "scenario_commitment": "abstain"}, + actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, round_num=1) + assert d.ok and d.option["id"] == ad.HOLD_OPTION_ID and d.scenario == ad.ABSTAIN + assert d.public_statement == "" # holding needs no statement + d2 = ad.parse_decision({"option_id": "public_statement"}, actor=world.actors[0], world=world, options=menu, + scenarios=world.scenarios, round_num=1) + assert d2.ok and d2.public_statement.startswith("Alpha Systems: ") # a move always has something to publish + + +def test_elicit_decision_with_fake_llm_and_failure(): + world = _world() + menu = _menu(world) + llm = FakeLLMClient(json_responses=[{"option_id": "plan_1", "public_statement": "Go.", + "scenario_commitment": {"scenario": "Permit delayed", "confidence": 0.5}}]) + d = ad.elicit_decision(llm, system_message="ROLE BRIEF", user_prompt="decide", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1) + assert d.ok and d.scenario == "Permit delayed" + assert llm.calls[0]["messages"][0] == {"role": "system", "content": "ROLE BRIEF"} + + class Boom: + def chat_json(self, **kwargs): + raise RuntimeError("provider down") + + failed = ad.elicit_decision(Boom(), system_message="", user_prompt="decide", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1) + assert failed.status == "failed" and failed.option["id"] == ad.HOLD_OPTION_ID + assert ad.elicit_decision(None, system_message="", user_prompt="x", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1).status == "failed" + counts = ad.summarize_decisions([d, failed]) + assert counts == {"ok": 1, "invalid": 0, "failed": 1, "language_mismatch": 0, "hold": 1} + assert failed.problems == ["llm error: RuntimeError: provider down"] + + +def test_memory_note_and_hold_note(): + world = _world() + menu = _menu(world) + d = ad.parse_decision({"option_id": "plan_1", "targets": ["Beta Union"], "public_statement": "Go.", + "private_rationale": "secret", "commitment": {"description": "Break ground", "due_periods": 1}}, + actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, round_num=1) + note = ad.decision_memory_note(d, "2026-Q3") + assert note.startswith("# YOUR MOVE THIS PERIOD — 2026-Q3") + assert "Directed at: Beta Union" in note and 'You announced: "Go."' in note + assert "You committed to: Break ground" in note and "secret" in note + hold = ad.hold_decision(world.actors[0], status="failed", round_num=1, period_end=None) + assert ad.decision_memory_note(hold) == "" + + +# ----------------------------------------------------------------------------- run language +@pytest.mark.parametrize("text, script", [ + ("We commit $2bn to Aurora now.", "latin"), + ("商务部将对石墨出口许可进行审查。", "cjk"), + ("MOFCOM (商务部) will review graphite export licences next month.", "latin"), # a quoted name stays Latin + ("商务部将审查石墨出口许可(USTR 第 301 条)", "cjk"), # acronyms stay CJK + ("東京は関税を25%に引き上げた。", "cjk"), + ("25% — 2027-03-10", ""), # no letters: unknown + ("", ""), +]) +def test_text_script_heuristic(text, script): + assert ad.text_script(text) == script + + +@pytest.mark.parametrize("language, script", [ + ("English", "latin"), ("en", "latin"), ("English (US)", "latin"), ("Deutsch", "latin"), ("Chinese", "cjk"), + ("zh-CN", "cjk"), ("中文", "cjk"), ("Japanese", "cjk"), ("", ""), ("auto", ""), ("Russian", ""), + ("Javanese", ""), ("Estonian", ""), +]) +def test_expected_script(language, script): + assert ad.expected_script(language) == script + assert ad.statement_matches_language("anything", "") and ad.statement_matches_language("", language) + + +def test_parse_rejects_wrong_script_statement_with_typed_status(): + """sim_d6ebcc77e22d: one of fifty statements came out in Chinese in an English run although the + prompt said "Write public_statement in English." — structure-only validation let it through.""" + world = _world() + menu = _menu(world) + raw = {"option_id": "plan_1", "targets": ["Beta Union"], "magnitude": 0.8, + "public_statement": "我们将于本季度向极光项目投入20亿美元。", + "scenario_commitment": {"scenario": "Permit granted", "confidence": 0.8}} + d = ad.parse_decision(raw, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1, period_end="2026-09-30", language="English") + assert d.status == ad.LANGUAGE_MISMATCH and not d.ok + assert d.option["id"] == ad.HOLD_OPTION_ID and d.raw_option_id == "plan_1" + assert d.public_statement == "" and d.scenario_commitment() is None # nothing to publish or commit + assert d.problems == ['public_statement is cjk-script but the run language is English: ' + '"我们将于本季度向极光项目投入20亿美元。"'] + row = d.to_dict() + assert row["status"] == "language_mismatch" and row["problems"] == d.problems + # the same payload is a valid move when the run language matches, or when none is set + assert ad.parse_decision(raw, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1, language="Chinese").ok + assert ad.parse_decision(raw, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1).ok + # an English run keeps a Latin statement that quotes a Chinese name; a Chinese run rejects English + en = dict(raw, public_statement="MOFCOM (商务部) will review the Aurora filing in Q3.") + assert ad.parse_decision(en, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1, language="English").ok + zh = ad.parse_decision(en, actor=world.actors[0], world=world, options=menu, scenarios=world.scenarios, + round_num=1, language="Chinese") + assert zh.status == ad.LANGUAGE_MISMATCH and zh.problems[0].startswith("public_statement is latin-script") + # holding publishes nothing, and a synthesised statement is never checked + hold = ad.parse_decision({"option_id": ad.HOLD_OPTION_ID, "public_statement": "保持观望。"}, actor=world.actors[0], + world=world, options=menu, scenarios=world.scenarios, round_num=1, language="English") + assert hold.ok + synth = ad.parse_decision({"option_id": "plan_1"}, actor=world.actors[0], world=world, options=menu, + scenarios=world.scenarios, round_num=1, language="Chinese") + assert synth.ok and synth.public_statement.startswith("Alpha Systems: ") + assert ad.summarize_decisions([d, zh, hold, synth]) == {"ok": 2, "invalid": 0, "failed": 0, + "language_mismatch": 2, "hold": 3} + + +class _Filtered: + """Stand-in whose only answer is the live Zhipu content-filter rejection.""" + + def chat_json(self, **kwargs): + raise content_filter_error() + + +def test_elicit_decision_applies_language_check_and_records_provider_errors(): + world = _world() + menu = _menu(world) + llm = FakeLLMClient(json_responses=[{"option_id": "plan_1", "public_statement": "我们现在就投资极光项目。"}]) + d = ad.elicit_decision(llm, system_message="ROLE", user_prompt="decide", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1, language="English") + assert d.status == ad.LANGUAGE_MISMATCH and d.raw_option_id == "plan_1" + + failed = ad.elicit_decision(_Filtered(), system_message="", user_prompt="decide", actor=world.actors[0], + world=world, options=menu, scenarios=world.scenarios, round_num=1) + assert failed.status == "failed" and failed.option["id"] == ad.HOLD_OPTION_ID + [problem] = failed.problems + assert problem.startswith("llm error: BadRequestError (HTTP 400, provider code 1301, content filter): " + "Error code: 400 - {'contentFilter'") + assert len(problem) <= 300 + assert ad.elicit_decision(None, system_message="", user_prompt="x", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1).problems == ["no llm client"] + + +def test_elicit_decision_recovers_through_fallback_provider(scripted_failover): + """The decision step goes through the backend LLMClient: a content-filter rejection is retried + ONCE on the configured fallback provider and the fallback's answer is the decision.""" + world = _world() + menu = _menu(world) + llm, transports = scripted_failover(content_filter_error(), { + "option_id": "plan_1", "public_statement": "We commit $2bn to Aurora now.", + "scenario_commitment": {"scenario": "Permit granted", "confidence": 0.7}}) + d = ad.elicit_decision(llm, system_message="ROLE", user_prompt="decide", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1, language="English") + assert d.ok and d.option["id"] == "plan_1" and d.scenario == "Permit granted" and d.problems == [] + assert transports == [("glm", False), ("kimi", True)] # one primary attempt, one fallback retry + + +def test_elicit_decision_records_fallback_failure(scripted_failover): + world = _world() + menu = _menu(world) + llm, transports = scripted_failover(content_filter_error(), fallback_error=content_filter_error("assistant")) + d = ad.elicit_decision(llm, system_message="ROLE", user_prompt="decide", actor=world.actors[0], world=world, + options=menu, scenarios=world.scenarios, round_num=1, language="English") + assert d.status == "failed" and d.option["id"] == ad.HOLD_OPTION_ID + assert "; fallback kimi: failed (BadRequestError (HTTP 400, provider code 1301, content filter)" in d.problems[0] + assert transports == [("glm", False), ("kimi", True)] # degrade-safe: no further attempts diff --git a/backend/tests/test_feed_rounds.py b/backend/tests/test_feed_rounds.py new file mode 100644 index 00000000..3beb6fd3 --- /dev/null +++ b/backend/tests/test_feed_rounds.py @@ -0,0 +1,199 @@ +"""Period view of the simulation feed: posts and replies indexed by the round they were +written in (``services.feed_rounds``) and served per round by the simulation API. + +The dashboard's World State panel selects a committed period; the feed must show that +period's posts (and the threads replied to in it) instead of the newest posts. Pins: action +rows map posts by (agent, content) and comments by id, content-only and previous-round +fallbacks stay honest about unmatched rows, period labels come from the world_update rows, +the per-round endpoints return exactly the round's rows with their ``round`` and the +legacy endpoints are untouched without ``round``; containment and bounds hold. +""" + +import json +import os +import sqlite3 +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.config import Config # noqa: E402 +from app.services import feed_rounds as fr # noqa: E402 + + +def _row(round_, agent, kind, **args): + return {"round": round_, "timestamp": "2026-10-17T00:00:00", "agent_id": agent, "agent_name": f"Agent{agent}", + "action_type": kind, "action_args": args, "result": None, "success": True} + + +def _write_run(sim_dir, platform="twitter", *, with_actions=True, symlink_actions=False): + os.makedirs(os.path.join(sim_dir, platform), exist_ok=True) + rows = [ + {"timestamp": "t", "event_type": "simulation_start", "platform": platform, "total_rounds": 3}, + {"round": 0, "event_type": "round_start", "simulated_hour": 0}, + _row(0, 6, "CREATE_POST", content="The cliff is here.\n\nStatutory controls now."), + _row(0, 0, "CREATE_POST", content="MOFCOM's position is consistent."), + {"round": 0, "event_type": "round_end", "actions_count": 2}, + {"round": 1, "event_type": "round_start", "simulated_hour": 1}, + {"round": 1, "event_type": "world_update", "period_label": "2026-11", "headline": "2026-11: 3 decisions"}, + _row(1, 2, "CREATE_POST", content="Case-by-case review continues."), + _row(1, 1, "CREATE_COMMENT", content="Noted, Bureau.", comment_id="1"), + _row(1, 3, "CREATE_POST", content="Shared text", ), + {"round": 1, "event_type": "round_end", "actions_count": 3}, + {"round": 2, "event_type": "round_start", "simulated_hour": 2}, + {"round": 2, "event_type": "world_update", "period_label": "2026-12", "headline": "2026-12: deal"}, + _row(2, 3, "CREATE_POST", content="Shared text"), # same text as round 1 → ambiguous + _row(2, 4, "CREATE_COMMENT", content="Verification first."), # no comment_id → key match + _row(2, 5, "CREATE_POST", content="Failed post", ) | {"success": False}, + {"round": 2, "event_type": "round_end", "actions_count": 3}, + "not json", + json.dumps({"round": "x", "action_type": "CREATE_POST"}), + ] + if with_actions: + path = os.path.join(sim_dir, platform, "actions.jsonl") + if symlink_actions: + real = os.path.join(sim_dir, "real_actions.jsonl") + with open(real, "w", encoding="utf-8") as f: + f.write("\n".join(r if isinstance(r, str) else json.dumps(r) for r in rows) + "\n") + os.symlink(real, path) + else: + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(r if isinstance(r, str) else json.dumps(r) for r in rows) + "\n") + db = sqlite3.connect(os.path.join(sim_dir, f"{platform}_simulation.db")) + db.executescript(""" + CREATE TABLE user (user_id INTEGER PRIMARY KEY, agent_id INTEGER, user_name TEXT, name TEXT); + CREATE TABLE post (post_id INTEGER PRIMARY KEY, user_id INTEGER, original_post_id INTEGER, content TEXT, + quote_content TEXT, created_at TEXT, num_likes INTEGER DEFAULT 0); + CREATE TABLE comment (comment_id INTEGER PRIMARY KEY, post_id INTEGER, user_id INTEGER, content TEXT, + created_at TEXT, num_likes INTEGER DEFAULT 0); + """) + # user ids are shifted by 10 so that the user → agent mapping is exercised + db.executemany("INSERT INTO user VALUES (?,?,?,?)", [(10 + a, a, None, f"agent_{a}") for a in range(7)]) + db.executemany("INSERT INTO post (post_id, user_id, content, created_at) VALUES (?,?,?,?)", [ + (1, 16, "The cliff is here.\n\nStatutory controls now.", "2026-10-17 00:52:18"), + (2, 10, "MOFCOM's position is consistent.", "2026-10-17 00:52:19"), # whitespace differs + (3, 12, "Case-by-case review continues.", "2026-10-17 01:52:18"), + (4, 13, "Shared text", "2026-10-17 01:52:19"), # first occurrence → round 1 + (5, 11, "Not in the log at all", "2026-10-17 01:52:20"), # unmatched → previous (1) + (6, 13, "Shared text", "2026-10-17 02:52:18"), # second occurrence → round 2 + ]) + db.executemany("INSERT INTO comment (comment_id, post_id, user_id, content, created_at) VALUES (?,?,?,?,?)", [ + (1, 3, 11, "Noted, Bureau.", "2026-10-17 01:53:00"), + (2, 1, 14, "Verification first.", "2026-10-17 02:53:00"), + (3, 2, 15, "Orphan reply", "2026-10-17 02:54:00"), + ]) + db.commit() + db.close() + + +def test_index_maps_posts_by_agent_and_content_and_comments_by_id(tmp_path): + sim_dir = str(tmp_path / "sim_a") + _write_run(sim_dir) + idx = fr.build_feed_round_index(sim_dir, "twitter") + assert idx["post_round"] == {1: 0, 2: 0, 3: 1, 4: 1, 5: 1, 6: 2} + assert idx["comment_round"] == {1: 1, 2: 2, 3: 2} + assert idx["unmatched_posts"] == 1 and idx["unmatched_comments"] == 1 + assert idx["truncated"] is False + rounds = {r["round"]: r for r in idx["rounds"]} + assert sorted(rounds) == [0, 1, 2] + assert rounds[1]["period_label"] == "2026-11" and rounds[1]["headline"] == "2026-11: 3 decisions" + assert rounds[0]["period_label"] == "" and rounds[0]["posts"] == 2 + assert rounds[1]["posts"] == 3 and rounds[1]["comments"] == 1 and rounds[2]["posts"] == 1 and rounds[2]["comments"] == 2 + # posts of round 2: the one created in it plus the two threads replied to in it + post_ids, comment_ids = fr.ids_for_round(idx, 2, fr.load_platform_rows(os.path.join(sim_dir, "twitter_simulation.db"))[1]) + assert post_ids == [1, 2, 6] and comment_ids == [2, 3] + assert fr.public_index(idx).keys() == {"platform", "rounds", "unmatched_posts", "unmatched_comments", "truncated"} + + +def test_missing_symlinked_or_oversized_artefacts_yield_an_empty_or_db_only_index(tmp_path): + empty = str(tmp_path / "sim_empty") + os.makedirs(empty) + idx = fr.build_feed_round_index(empty, "twitter") + assert idx["rounds"] == [] and idx["post_round"] == {} + linked = str(tmp_path / "sim_link") + _write_run(linked, symlink_actions=True) + idx = fr.build_feed_round_index(linked, "twitter") + assert idx["rounds"] and all(r["period_label"] == "" for r in idx["rounds"]) # log ignored, posts fall back + assert idx["unmatched_posts"] == 6 and set(idx["post_round"].values()) == {0} + big = str(tmp_path / "sim_big") + _write_run(big) + acts = fr.read_action_rounds(os.path.join(big, "twitter", "actions.jsonl"), max_bytes=200) + assert acts["truncated"] is True + + +def test_cache_follows_the_artefacts(tmp_path): + sim_dir = str(tmp_path / "sim_c") + _write_run(sim_dir) + first = fr.cached_feed_round_index(sim_dir, "twitter") + assert fr.cached_feed_round_index(sim_dir, "twitter") is first + with open(os.path.join(sim_dir, "twitter", "actions.jsonl"), "a", encoding="utf-8") as f: + f.write(json.dumps({"round": 3, "event_type": "round_start", "simulated_hour": 3}) + "\n") + os.utime(os.path.join(sim_dir, "twitter", "actions.jsonl"), (1, 1)) + second = fr.cached_feed_round_index(sim_dir, "twitter") + assert second is not first and [r["round"] for r in second["rounds"]] == [0, 1, 2, 3] + + +# ----------------------------------------------------------------------------- API +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + from app import create_app + app = create_app() + app.config["TESTING"] = True + return app.test_client() + + +def test_feed_rounds_endpoint_and_per_round_posts_and_comments(client, tmp_path): + _write_run(str(tmp_path / "sim_api")) + res = client.get("/api/simulation/sim_api/feed-rounds?platform=twitter") + assert res.status_code == 200 + data = res.get_json()["data"] + assert [r["round"] for r in data["rounds"]] == [0, 1, 2] + assert data["rounds"][2]["period_label"] == "2026-12" and "post_round" not in data + + res = client.get("/api/simulation/sim_api/posts?platform=twitter&round=1&limit=10") + data = res.get_json()["data"] + assert data["round"] == 1 and data["period_label"] == "2026-11" and data["total"] == 3 + assert [p["post_id"] for p in data["posts"]] == [5, 4, 3] # newest first within the round + assert all(p["round"] == 1 for p in data["posts"]) + res = client.get("/api/simulation/sim_api/posts?platform=twitter&round=2") + data = res.get_json()["data"] + assert [p["post_id"] for p in data["posts"]] == [6, 2, 1] and [p["round"] for p in data["posts"]] == [2, 0, 0] + res = client.get("/api/simulation/sim_api/comments?platform=twitter&round=2") + data = res.get_json()["data"] + assert [c["comment_id"] for c in data["comments"]] == [3, 2] and all(c["round"] == 2 for c in data["comments"]) + assert client.get("/api/simulation/sim_api/posts?platform=twitter&round=7").get_json()["data"]["posts"] == [] + # paging inside a round + data = client.get("/api/simulation/sim_api/posts?platform=twitter&round=1&limit=2&offset=2").get_json()["data"] + assert [p["post_id"] for p in data["posts"]] == [3] and data["total"] == 3 + + +def test_round_filter_validation_and_containment(client, tmp_path): + _write_run(str(tmp_path / "sim_v")) + assert client.get("/api/simulation/sim_v/posts?platform=twitter&round=abc").status_code == 400 + assert client.get("/api/simulation/sim_v/posts?platform=../x&round=1").status_code == 400 + assert client.get("/api/simulation/nope/posts?platform=twitter&round=1").status_code == 404 + assert client.get("/api/simulation/..%2F..%2Fetc/feed-rounds").status_code == 404 + assert client.get("/api/simulation/nope/feed-rounds").status_code == 404 + os.makedirs(str(tmp_path / "sim_fresh")) + assert client.get("/api/simulation/sim_fresh/feed-rounds?platform=twitter").get_json()["data"]["rounds"] == [] + + +def test_legacy_endpoints_without_round_are_unchanged(client, tmp_path, monkeypatch): + # the legacy routes resolve the directory relative to the API module; point both at tmp + import app.api.simulation as api_mod + real_join = os.path.join + + def _join(*parts): + if len(parts) == 2 and str(parts[1]).startswith("../../uploads/simulations/"): + return real_join(str(tmp_path), str(parts[1]).split("/")[-1]) + return real_join(*parts) + + monkeypatch.setattr(api_mod.os.path, "join", _join) + _write_run(str(tmp_path / "sim_legacy")) + data = client.get("/api/simulation/sim_legacy/posts?platform=twitter&limit=3").get_json()["data"] + assert data["total"] == 6 and [p["post_id"] for p in data["posts"]] == [6, 5, 4] and "round" not in data + assert "round" not in data["posts"][0] + data = client.get("/api/simulation/sim_legacy/comments?platform=twitter").get_json()["data"] + assert data["count"] == 3 and "round" not in data["comments"][0] diff --git a/backend/tests/test_forecast_binary_resilience.py b/backend/tests/test_forecast_binary_resilience.py new file mode 100644 index 00000000..df3e0f1a --- /dev/null +++ b/backend/tests/test_forecast_binary_resilience.py @@ -0,0 +1,161 @@ +"""Content-filter-safe binary extraction (report stage) and the truncation lint refinement. + +Live defect: glm-5.3 content-filtered the single 48k-character extraction prompt +(HTTP 400 code 1301), no fallback provider was configured, binary_count stayed 0 and +the publish gate failed the whole report. Pins: chunked retry skips filtered chunks +and merges the rest; the scenario-partition fallback is deterministic, labelled and +consistent with the partition; non-filter errors still propagate; a complete table +cell ending after a parenthesis is not a truncation while a real width cut still is. +""" + +import json + +import pytest + +from app.services import forecast_binary_resilience as fbr +from app.services import report_lint as rl +from app.services.forecast_extractor import audit_proposition_consistency +from tests.conftest import FakeLLMClient + + +class _ContentFilterError(Exception): + pass + + +_FILTER_TEXT = ("Error code: 400 - {'contentFilter': [{'level': 1, 'role': 'user'}], 'error': " + "{'code': '1301', 'message': '系统检测到输入或生成内容可能包含不安全或敏感内容'}}") + + +def _report(sections=6, size=4000): + return "\n".join(f"## Section {i}\n" + (f"Paragraph {i} about trade policy. " * (size // 36)) for i in range(sections)) + + +_SCENARIOS = [ + {"name": "Fragile truce holds", "probability": 0.38, + "resolution_criteria": "By 2027-06-30: average US tariff on Chinese imports is between 30% and 45%", + "base_rate_anchor": "n=7 episodes", "adjustment_rationale": "regressed from 0.45"}, + {"name": "Deal deepens", "probability": 0.27, "resolution_criteria": "By 2027-06-30: tariff below 30%"}, + {"name": "Collapse into escalation", "probability": 0.2, "resolution_criteria": "By 2027-06-30: tariff above 45%"}, + {"name": "Status quo drift", "probability": 0.15, "resolution_criteria": "By 2027-06-30: no agreement in force"}, +] + + +def _row(statement, prob, theme="trade"): + return {"statement": statement, "probability": prob, "resolution_criteria": "PIIE average tariff by 2027-06-30 above 40%", + "theme": theme, "horizon_year": 2027} + + +# ----------------------------------------------------------------------------- chunking +def test_split_markdown_chunks_packs_sections_and_keeps_head_and_tail(): + md = _report(sections=10, size=3000) + chunks = fbr.split_markdown_chunks(md, chunk_chars=7000, max_chunks=4) + assert 1 <= len(chunks) <= 4 + assert chunks[0].startswith("## Section 0") + assert "## Section 9" in chunks[-1] + assert all(len(c) <= 7000 for c in chunks) + assert fbr.split_markdown_chunks("", chunk_chars=5000) == [] + big = "## Only\n" + "x" * 20000 + assert all(len(c) <= 5000 for c in fbr.split_markdown_chunks(big, chunk_chars=5000, max_chunks=0)) + + +# ----------------------------------------------------------------------------- chunked retry +def test_chunked_retry_skips_filtered_chunks_and_merges_rows(): + calls = [] + + def extractor(markdown, llm, *, min_count, **kwargs): + calls.append((len(markdown), min_count)) + if len(markdown) > 10000: + raise _ContentFilterError(_FILTER_TEXT) # the whole dossier is rejected + if "Section 1" in markdown and "Section 2" not in markdown: + raise _ContentFilterError(_FILTER_TEXT) # one chunk is sensitive + n = int(markdown.split("## Section ")[1].split("\n")[0]) + rows = [_row(f"Tariff statement {n} above {40 + n}% by 2027-06-30", 0.2 + 0.05 * n), + _row("Shared statement across chunks", 0.6)] + return {"binary_forecasts": rows, "binary_quality": {"passed": True}, + "market_comparison": {"comparisons": [{"id": f"m{n}"}]}} + + md = _report(sections=4, size=4500) + res = fbr.extract_binary_forecasts_resilient(md, FakeLLMClient(), min_count=10, chunk_chars=5000, + max_chunks=4, extractor=extractor, scenarios=_SCENARIOS, + themes=["trade"]) + rows = res["binary_forecasts"] + assert [r["id"] for r in rows] == [f"F{i}" for i in range(1, len(rows) + 1)] + statements = [r["statement"] for r in rows] + assert statements.count("Shared statement across chunks") == 1 # merged by statement key + assert not any("statement 1 " in s for s in statements) # filtered chunk skipped + assert res["recovery"]["mode"] == "chunked_after_content_filter" + assert res["recovery"]["filtered_chunks"] == 1 and res["recovery"]["failed_chunks"] == 0 + assert res["recovery"]["primary_error"].startswith("_ContentFilterError") + assert calls[0][1] == 10 and all(c[1] == 3 for c in calls[1:]) # per-chunk minimum + assert len(res["market_comparison"]["comparisons"]) >= 2 + assert "passed" in res["binary_quality"] + + +def test_non_filter_errors_propagate_and_empty_results_pass_through(): + def boom(markdown, llm, *, min_count, **kwargs): + raise RuntimeError("provider down") + + with pytest.raises(RuntimeError): + fbr.extract_binary_forecasts_resilient("## A\ntext", FakeLLMClient(), min_count=5, extractor=boom) + + def empty(markdown, llm, *, min_count, **kwargs): + return {"binary_forecasts": [], "binary_quality": {"passed": False}} + + res = fbr.extract_binary_forecasts_resilient("## A\ntext", FakeLLMClient(), min_count=5, extractor=empty) + assert res["binary_forecasts"] == [] and "recovery" not in res + + +# ----------------------------------------------------------------------------- scenario fallback +def test_scenario_partition_fallback_is_deterministic_labelled_and_consistent(): + def always_filtered(markdown, llm, *, min_count, **kwargs): + raise _ContentFilterError(_FILTER_TEXT) + + res = fbr.extract_binary_forecasts_resilient(_report(sections=3), FakeLLMClient(), min_count=10, + extractor=always_filtered, scenarios=_SCENARIOS, + horizon_date="2027-06-30", language="English") + rows = res["binary_forecasts"] + assert len(rows) == 4 and [r["id"] for r in rows] == ["F1", "F2", "F3", "F4"] + assert rows[0]["statement"] == 'By 2027-06-30, the realized outcome falls within the scenario "Fragile truce holds".' + assert rows[0]["probability"] == 0.38 and rows[0]["source"] == "scenario-partition" + assert rows[0]["theme"] == "scenario" and rows[0]["horizon_year"] == 2027 + assert rows[0]["scenario_membership"]["derivable"] is True + assert rows[0]["scenario_membership"]["yes_scenarios"] == ["Fragile truce holds"] + assert rows[0]["criteria_sharp"] is True + assert rows[0]["base_rate_anchor"] == "n=7 episodes" + assert res["binary_quality"]["passed"] is False + assert any("情景分区" in issue for issue in res["binary_quality"]["issues"]) + assert res["recovery"]["mode"] == "scenario_partition_fallback" and res["recovery"]["rows"] == 4 + # the derived rows satisfy the proposition-consistency audit by construction + audit = audit_proposition_consistency({"scenarios": _SCENARIOS, "binary_forecasts": rows}) + assert audit["passed"] is True and audit["checked"] == 4 + # deterministic + again = fbr.extract_binary_forecasts_resilient(_report(sections=3), FakeLLMClient(), min_count=10, + extractor=always_filtered, scenarios=_SCENARIOS, + horizon_date="2027-06-30", language="English") + assert json.dumps(again["binary_forecasts"], sort_keys=True) == json.dumps(rows, sort_keys=True) + + +def test_scenario_partition_rows_in_chinese_and_without_horizon(): + rows = fbr.scenario_partition_binaries(_SCENARIOS[:1], horizon_date=None, language="Chinese") + assert rows[0]["statement"] == "到预测判定日,实际结果落入情景「Fragile truce holds」。" + assert rows[0]["horizon_year"] is None + assert fbr.scenario_partition_binaries([{"name": "", "probability": 0.3}, {"name": "x", "probability": "n/a"}, None]) == [] + + +# ----------------------------------------------------------------------------- truncation lint +_COMPLETE_CELL = ("Average US tariff stays in the 30-45% band; the BIS case-by-case H200 regime survives, " + "hemmed in but not reversed; at least one implementation pillar (soybeans, general licences, " + "tariff-cut lists) demonstrably incomplete") +_CUT_CELL = ("Average US tariff stays in the 30-45% band; the BIS case-by-case H200 regime survives, hemmed in " + "but not reversed; at least one implementation pillar (soybeans, general licences, tariff-cut lists) " + "demonstrably incomplete and the") + + +def test_complete_cell_ending_after_parenthesis_is_not_truncated_but_width_cuts_are(): + assert rl._ends_mid_clause(_COMPLETE_CELL) is False + assert rl._ends_mid_clause(_CUT_CELL) is True # dangling "the" + assert rl._ends_mid_clause("Unbalanced (parenthesis never closed in this very long cell of text") is True + assert rl._ends_mid_clause("x" * 200) is True # machine-length token + md = "| Scenario | Criteria |\n| --- | --- |\n| A | " + _COMPLETE_CELL + " |\n| B | " + _CUT_CELL + " |\n" + flagged = rl.detect_table_cell_truncation(md) + assert len(flagged) == 1 and flagged[0].endswith("and the") diff --git a/backend/tests/test_llm_errors.py b/backend/tests/test_llm_errors.py new file mode 100644 index 00000000..21e8d53e --- /dev/null +++ b/backend/tests/test_llm_errors.py @@ -0,0 +1,68 @@ +"""Provider-agnostic LLM error description (``app.utils.llm_errors``) and the failover note +``LLMClient.chat`` attaches when the fallback provider cannot take a call over. + +Offline: the real LLMClient runs with scripted transports (conftest ``scripted_failover``). +""" + +import pytest + +from app.utils import llm_errors as le +from tests.conftest import content_filter_error + + +def test_describe_content_filter_error_from_sdk_attributes(): + exc = content_filter_error() + assert le.error_status(exc) == 400 and le.error_code(exc) == "1301" + assert le.is_content_filter_error(exc) and le.is_non_retryable_request_error(exc) + assert le.classify_llm_error(exc) == "content filter" + text = le.describe_llm_error(exc, max_chars=60) + head = "BadRequestError (HTTP 400, provider code 1301, content filter): " + assert text.startswith(head + "Error code: 400 - {'contentFilter'") + assert len(text) <= len(head) + 60 + details = le.llm_error_details(exc) + assert details["type"] == "BadRequestError" and details["status"] == 400 and details["code"] == "1301" + assert details["kind"] == "content filter" and details["failover"] == "" + + +def test_describe_falls_back_to_message_parsing(): + class Weird(Exception): + pass + + exc = Weird('Error code: 422 - {"error": {"code": "new_sensitive", "message": "blocked"}}') + assert le.error_status(exc) == 422 and le.error_code(exc) == "new_sensitive" + assert le.is_content_filter_error(exc) and le.classify_llm_error(exc) == "content filter" + plain = RuntimeError("arbiter provider down") + assert le.error_status(plain) is None and le.error_code(plain) == "" and le.classify_llm_error(plain) == "" + assert le.describe_llm_error(plain) == "RuntimeError: arbiter provider down" + assert not le.is_non_retryable_request_error(plain) + quota = RuntimeError("HTTP 429 rate_limit: slow down") + assert le.classify_llm_error(quota) == "quota/rate limit" and not le.is_non_retryable_request_error(quota) + auth = RuntimeError("Error code: 401 - invalid authentication credentials") + assert le.classify_llm_error(auth) == "authentication" and le.is_non_retryable_request_error(auth) + assert le.describe_llm_error(RuntimeError("")) == "RuntimeError: RuntimeError('')" + + +def test_failover_notes_reach_the_description(scripted_failover): + primary_error = content_filter_error() + llm, transports = scripted_failover(primary_error, fallback_error=content_filter_error("assistant")) + with pytest.raises(type(primary_error)) as info: + llm.chat_json([{"role": "user", "content": "q"}]) + note = le.failover_notes(info.value) + assert note.startswith("fallback kimi: failed (BadRequestError (HTTP 400, provider code 1301, content filter): ") + assert transports == [("glm", False), ("kimi", True)] + assert "; " + note in le.describe_llm_error(info.value) + + unconfigured, transports2 = scripted_failover(content_filter_error(), fallback_provider=None) + with pytest.raises(type(primary_error)) as info2: + unconfigured.chat_json([{"role": "user", "content": "q"}]) + assert le.failover_notes(info2.value) == "fallback: not configured (LLM_FALLBACK_PROVIDER unset)" + assert transports2 == [("glm", False)] + + +def test_failover_note_is_attached_once(scripted_failover): + err = content_filter_error() + llm, _ = scripted_failover(err, fallback_provider=None) + for _ in range(2): + with pytest.raises(type(err)): + llm.chat_json([{"role": "user", "content": "q"}]) + assert len([n for n in (getattr(err, "__notes__", None) or []) if str(n).startswith("fallback")]) == 1 diff --git a/backend/tests/test_report_filter_safe.py b/backend/tests/test_report_filter_safe.py new file mode 100644 index 00000000..6425d0f2 --- /dev/null +++ b/backend/tests/test_report_filter_safe.py @@ -0,0 +1,392 @@ +"""Content-filter-safe section generation (report stage). + +Live defect (pipe_d569f211eb14, glm-5.3): one chapter was content-filtered on all six +attempts and the publish gate refused the whole report. The agent log showed a complete, +filter-clean draft rejected only for the tool-call floor, after which the accumulated +retrieval dumps tripped the provider's input filter; the identical retries (and the LLM +cache) replayed the failure. Live probes showed the brief and an institutional-register +directive pass while rewriting leaders' names as offices is itself a trigger. + +Pins: a draft rejected only for the tool-call floor is accepted when a content filter +interrupts the attempt (both generators, loop and forced-finish calls), recorded as level 0, +never for other errors or without a draft; the retry wrapper climbs the ladder only on a +content filter, without backoff, records the level, keeps the legacy backoff path for other +errors and can be switched off; the native path no longer falls back to ReAct on a content +filter; level 1 adds the directive, lowers the floor to one and caps passages; level 2 +writes tool-free; the tool-call parser accepts flat and OpenAI-style arguments; the report +discloses the chapters written this way. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.config import Config # noqa: E402 +from app.services import report_agent as ra # noqa: E402 +from app.services import report_filter_safe as fs # noqa: E402 +from app.services.report_agent import ( # noqa: E402 + Report, ReportAgent, ReportOutline, ReportSection, ReportStatus, SECTION_FAILURE_PLACEHOLDER, +) + + +class _ContentFilterError(Exception): + pass + + +_FILTER_TEXT = ("Error code: 400 - {'contentFilter': [{'level': 1, 'role': 'user'}], 'error': " + "{'code': '1301', 'message': '系统检测到输入或生成内容可能包含不安全或敏感内容'}}") +_BODY = "The commerce ministry keeps the suspension in force while tightening licensing. " * 20 +_TOOL_CALL = '{"name": "quick_search", "parameters": {"query": "who decides"}}' + + +def _bare_agent(**over): + a = ReportAgent.__new__(ReportAgent) + a.graph_id = "g1" + a.simulation_id = "sim1" + a.simulation_requirement = "What happens to the truce?" + a.situation_brief = "" + a.actors = None + a.sources = [] + a.research_report = "" + a.output_language = "English" + a.scenario_label = "" + a.base_simulation_id = None + a._background_block = "" + a._sources_index = "" + a._signal_pack = "" + a._forecast_spine = None + a._forecast_spine_block = "" + a._retrieval_query = None + a._outline_degraded = False + a._outline_summary = "" + a._section_tool_calls = 0 + a.report_logger = None + a.console_logger = None + a.tools = {"quick_search": {"name": "quick_search", "description": "d", "parameters": {}}, + "insight_forge": {"name": "insight_forge", "description": "d", "parameters": {}}} + a.MIN_TOOL_CALLS_PER_SECTION = 2 + a.MAX_TOOL_CALLS_PER_SECTION = 4 + for k, v in over.items(): + setattr(a, k, v) + return a + + +def _section(): + return ReportSection( + title="The Actor Architecture: Who Actually Decides the Outcome", + description="Trump's personal control of tariff levels; Xi's preference for extending truces [S25].") + + +def _outline(section): + return ReportOutline(title="T", summary="S", sections=[section]) + + +# ----------------------------------------------------------------------------- module +def test_directive_levels_caps_and_detector(): + assert fs.register_directive(0) == "" + assert "Institutional-register mode" in fs.register_directive(1) + assert "must not issue tool calls" not in fs.register_directive(1) + assert "must not issue tool calls" in fs.register_directive(2) + assert "Xi" not in fs.register_directive(2) and "top leader" not in fs.register_directive(2) + assert fs.cap_observation("x" * 7000, 0) == "x" * 7000 + capped = fs.cap_observation("x" * 7000, 1) + assert capped.startswith("x" * 6000) and "passage capped" in capped and len(capped) < 6200 + assert fs.cap_observation(None, 1) == "" + assert fs.is_content_filter(_ContentFilterError(_FILTER_TEXT)) is True + assert fs.is_content_filter(RuntimeError("transient 500")) is False + assert fs.level_label(0).startswith("L0") and fs.level_label(2).startswith("L2") + + +# ----------------------------------------------------------------------------- retry ladder +def _ladder_agent(monkeypatch, script, retries=2): + monkeypatch.setattr(Config, "REPORT_SECTION_RETRY_MAX", retries, raising=False) + monkeypatch.setattr(Config, "REPORT_SECTION_RETRY_BACKOFF_S", 8.0, raising=False) + sleeps = [] + monkeypatch.setattr(ra.time, "sleep", lambda s: sleeps.append(s)) + a = _bare_agent() + seen = [] + + def _gen(section, outline, previous_sections, progress_callback=None, section_index=0, **kw): + seen.append(dict(kw)) + action = script[min(len(seen) - 1, len(script) - 1)] + if isinstance(action, Exception): + raise action + return action + + a._generate_section = _gen + return a, seen, sleeps + + +def test_content_filter_climbs_ladder_without_backoff_and_is_recorded(monkeypatch): + a, seen, sleeps = _ladder_agent( + monkeypatch, [_ContentFilterError(_FILTER_TEXT), _ContentFilterError(_FILTER_TEXT), "ok-body"]) + section = _section() + assert a._generate_section_with_retry(section, _outline(section), []) == "ok-body" + assert seen == [{}, {"filter_safe": 1}, {"filter_safe": 2}] + assert sleeps == [] # no pointless backoff + assert a._filter_safe_sections == {section.title: 2} + + +def test_level_one_success_is_recorded_as_level_one(monkeypatch): + a, seen, sleeps = _ladder_agent(monkeypatch, [_ContentFilterError(_FILTER_TEXT), "ok-body"]) + section = _section() + assert a._generate_section_with_retry(section, _outline(section), []) == "ok-body" + assert seen == [{}, {"filter_safe": 1}] and sleeps == [] + assert a._filter_safe_sections == {section.title: 1} + + +def test_transient_errors_keep_the_legacy_backoff_path(monkeypatch): + a, seen, sleeps = _ladder_agent(monkeypatch, [RuntimeError("transient 500"), "ok-body"]) + section = _section() + assert a._generate_section_with_retry(section, _outline(section), []) == "ok-body" + assert seen == [{}, {}] # never a filter_safe kwarg + assert sleeps == [8.0] + assert not getattr(a, "_filter_safe_sections", None) + + +def test_filter_at_top_level_falls_back_to_backoff_then_raises(monkeypatch): + a, seen, sleeps = _ladder_agent(monkeypatch, [_ContentFilterError(_FILTER_TEXT)], retries=3) + section = _section() + with pytest.raises(_ContentFilterError): + a._generate_section_with_retry(section, _outline(section), []) + assert seen == [{}, {"filter_safe": 1}, {"filter_safe": 2}, {"filter_safe": 2}] + assert sleeps == [24.0] # one legacy backoff at the top rung + assert not getattr(a, "_filter_safe_sections", None) + + +def test_ladder_off_switch_restores_legacy_identical_retries(monkeypatch): + monkeypatch.setattr(Config, "REPORT_FILTER_SAFE_LADDER", False, raising=False) + a, seen, sleeps = _ladder_agent(monkeypatch, [_ContentFilterError(_FILTER_TEXT), "ok-body"]) + section = _section() + assert a._generate_section_with_retry(section, _outline(section), []) == "ok-body" + assert seen == [{}, {}] and sleeps == [8.0] + + +def test_no_retries_configured_is_unchanged(monkeypatch): + a, seen, sleeps = _ladder_agent(monkeypatch, [_ContentFilterError(_FILTER_TEXT)], retries=0) + section = _section() + with pytest.raises(_ContentFilterError): + a._generate_section_with_retry(section, _outline(section), []) + assert seen == [{}] and sleeps == [] + + +def test_dispatcher_skips_react_fallback_on_content_filter_only(): + class _LLM: + def supports_native_tools(self): + return True + + a = _bare_agent(llm=_LLM()) + react_calls = [] + a._generate_section_react = lambda *args, **kw: react_calls.append(kw) or "react-body" + a._generate_section_native = lambda *args, **kw: (_ for _ in ()).throw(_ContentFilterError(_FILTER_TEXT)) + section = _section() + with pytest.raises(_ContentFilterError): + a._generate_section(section, _outline(section), [], None, 1, filter_safe=1) + assert react_calls == [] + a._generate_section_native = lambda *args, **kw: (_ for _ in ()).throw(RuntimeError("native 500")) + assert a._generate_section(section, _outline(section), [], None, 1, filter_safe=1) == "react-body" + assert react_calls == [{"filter_safe": 1}] + + +# ----------------------------------------------------------------------------- ReAct path +class _ReactLLM: + """Scripted chat(): a str is returned, an Exception is raised, None is returned as None.""" + + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def chat(self, messages, temperature=0.5, max_tokens=4096, **kw): + self.calls.append([dict(m) for m in messages]) + item = self.responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + def supports_native_tools(self): + return False + + +def test_react_accepts_the_floor_rejected_draft_when_retrieval_is_content_filtered(): + # the live sequence: one tool call, a complete Final Answer rejected for 1 < 2 tool calls, + # the model keeps retrieving, the next request is content-filtered on the input side + llm = _ReactLLM([_TOOL_CALL, "Final Answer: " + _BODY, _TOOL_CALL, _ContentFilterError(_FILTER_TEXT)]) + a = _bare_agent(llm=llm) + a._execute_tool = lambda name, params, report_context="": "retrieved passage [S3]" + section = _section() + out = a._generate_section_react(section, _outline(section), [], None, 6) + assert out == _BODY.strip() + assert len(llm.calls) == 4 + assert a._filter_safe_sections == {section.title: 0} + + +def test_react_without_a_draft_or_with_other_errors_still_raises(): + llm = _ReactLLM([_TOOL_CALL, _ContentFilterError(_FILTER_TEXT)]) + a = _bare_agent(llm=llm) + a._execute_tool = lambda *args, **kw: "passage" + section = _section() + with pytest.raises(_ContentFilterError): + a._generate_section_react(section, _outline(section), [], None, 6) + llm2 = _ReactLLM([_TOOL_CALL, "Final Answer: " + _BODY, RuntimeError("provider 500")]) + a2 = _bare_agent(llm=llm2) + a2._execute_tool = lambda *args, **kw: "passage" + with pytest.raises(RuntimeError): + a2._generate_section_react(section, _outline(section), [], None, 6) + assert not getattr(a2, "_filter_safe_sections", None) + # a contaminated / too-short Final Answer is never kept as the draft + llm3 = _ReactLLM([_TOOL_CALL, "Final Answer: too short", _ContentFilterError(_FILTER_TEXT)]) + a3 = _bare_agent(llm=llm3) + a3._execute_tool = lambda *args, **kw: "passage" + with pytest.raises(_ContentFilterError): + a3._generate_section_react(section, _outline(section), [], None, 6) + + +def test_react_draft_acceptance_respects_the_off_switch(monkeypatch): + monkeypatch.setattr(Config, "REPORT_FILTER_SAFE_LADDER", False, raising=False) + llm = _ReactLLM([_TOOL_CALL, "Final Answer: " + _BODY, _TOOL_CALL, _ContentFilterError(_FILTER_TEXT)]) + a = _bare_agent(llm=llm) + a._execute_tool = lambda *args, **kw: "passage" + section = _section() + with pytest.raises(_ContentFilterError): + a._generate_section_react(section, _outline(section), [], None, 6) + + +def test_react_level_one_adds_directive_lowers_floor_and_caps_observations(): + llm = _ReactLLM([_TOOL_CALL, "Final Answer: " + _BODY]) + a = _bare_agent(llm=llm, _background_block="【背景档案】Xi Jinping and He Lifeng negotiate; Trump sets tariffs.") + a._execute_tool = lambda name, params, report_context="": "passage [S3] " + "y" * 9000 + section = _section() + out = a._generate_section_react(section, _outline(section), ["## Baseline\n\nprior chapter"], None, 6, + filter_safe=1) + assert out == _BODY.strip() # 1 tool call now satisfies the floor + system = llm.calls[0][0]["content"] + assert "Xi Jinping and He Lifeng negotiate" in system # names are not rewritten + assert "Institutional-register mode" in system and "must not issue tool calls" not in system + observation = llm.calls[1][-1]["content"] + assert "passage [S3]" in observation and "passage capped" in observation + assert observation.count("y") < 6100 + + +def test_react_level_zero_is_untouched(): + llm = _ReactLLM([_TOOL_CALL, "Final Answer: " + _BODY, _TOOL_CALL, "Final Answer: " + _BODY]) + a = _bare_agent(llm=llm, _background_block="Background block.") + a._execute_tool = lambda name, params, report_context="": "passage " + "y" * 9000 + section = _section() + out = a._generate_section_react(section, _outline(section), ["prior"], None, 6) + assert out == _BODY.strip() + assert len(llm.calls) == 4 # floor of 2 enforced as before + assert "Institutional-register mode" not in llm.calls[0][0]["content"] + assert llm.calls[1][-1]["content"].count("y") == 9000 # no cap at level 0 + + +def test_react_level_two_writes_without_tools_and_handles_contamination(): + llm = _ReactLLM(["Final Answer: " + _BODY]) + a = _bare_agent(llm=llm) + a._execute_tool = lambda *args, **kw: pytest.fail("tools must not run at level 2") + section = _section() + prior = "## Baseline\n\n" + ("Xi Jinping met Trump. " * 400) + out = a._generate_section_react(section, _outline(section), [prior], None, 6, filter_safe=2) + assert out == _BODY.strip() + assert len(llm.calls) == 1 + system, user = llm.calls[0][0]["content"], llm.calls[0][1]["content"] + assert "must not issue tool calls" in system + assert user.rstrip().endswith("开头输出本章完整 Markdown 正文。") + assert 100 < user.count("met Trump") <= 190 and "..." in user # prior chapter capped at 4000 chars + llm2 = _ReactLLM(["Final Answer: too short"]) + a2 = _bare_agent(llm=llm2) + assert a2._generate_section_react(section, _outline(section), [], None, 6, filter_safe=2) == SECTION_FAILURE_PLACEHOLDER + llm3 = _ReactLLM([None]) + a3 = _bare_agent(llm=llm3) + with pytest.raises(RuntimeError): + a3._generate_section_react(section, _outline(section), [], None, 6, filter_safe=2) + + +# ----------------------------------------------------------------------------- native path +class _NativeLLM: + """Scripted chat_with_tools(): dict responses in order; an Exception is raised.""" + + def __init__(self, responses): + self.responses = list(responses) + self.tool_calls = [] + self.chat_calls = [] + + def chat_with_tools(self, messages, schemas, temperature=0.5, max_tokens=4096): + self.tool_calls.append(([dict(m) for m in messages], list(schemas))) + item = self.responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + def chat(self, messages, temperature=0.5, max_tokens=4096, **kw): + self.chat_calls.append([dict(m) for m in messages]) + return _BODY + + def supports_native_tools(self): + return True + + +_NATIVE_CALL = {"content": "", "tool_calls": [{"id": "c1", "name": "quick_search", "arguments": {"query": "q"}}]} + + +def test_native_accepts_floor_rejected_draft_and_caps_passages_at_level_one(): + llm = _NativeLLM([_NATIVE_CALL, {"content": _BODY, "tool_calls": []}, _NATIVE_CALL, + _ContentFilterError(_FILTER_TEXT)]) + a = _bare_agent(llm=llm) + a._execute_tool = lambda name, args, report_context="": "passage [S3]" + section = _section() + assert a._generate_section_native(section, _outline(section), [], None, 6) == _BODY.strip() + assert a._filter_safe_sections == {section.title: 0} + + llm2 = _NativeLLM([_NATIVE_CALL, {"content": _BODY, "tool_calls": []}]) + a2 = _bare_agent(llm=llm2, _background_block="Xi Jinping negotiates.") + a2._execute_tool = lambda name, args, report_context="": "passage " + "y" * 9000 + out = a2._generate_section_native(section, _outline(section), ["prior"], None, 6, filter_safe=1) + assert out == _BODY # floor lowered to 1 + system = llm2.tool_calls[0][0][0]["content"] + assert "Xi Jinping negotiates." in system and "Institutional-register mode" in system + tool_msg = [m for m in llm2.tool_calls[1][0] if m.get("role") == "tool"][0] + assert tool_msg["content"].count("y") < 6100 and "passage capped" in tool_msg["content"] + + llm3 = _NativeLLM([]) + a3 = _bare_agent(llm=llm3) + a3._execute_tool = lambda *args, **kw: pytest.fail("tools must not run at level 2") + assert a3._generate_section_native(section, _outline(section), [], None, 6, filter_safe=2) == _BODY.strip() + assert llm3.tool_calls == [] and len(llm3.chat_calls) == 1 + assert "must not issue tool calls" in llm3.chat_calls[0][0]["content"] + + +# ----------------------------------------------------------------------------- parser +def test_parse_tool_calls_accepts_flat_and_openai_style_arguments(): + a = _bare_agent() + flat = a._parse_tool_calls('\n{"name": "quick_search", "query": "Trump Xi roles", "limit": 10}\n') + assert flat[0]["name"] == "quick_search" + assert flat[0]["parameters"] == {"query": "Trump Xi roles", "limit": 10} + openai_style = a._parse_tool_calls('{"name": "quick_search", "arguments": {"query": "q"}}') + assert openai_style[0]["parameters"] == {"query": "q"} + bare = a._parse_tool_calls('{"name": "coalition_map"}') + assert bare[0]["name"] == "coalition_map" and bare[0].get("parameters", {}) == {} + legacy = a._parse_tool_calls('{"tool": "quick_search", "params": {"query": "q"}}') + assert legacy[0]["name"] == "quick_search" and legacy[0]["parameters"] == {"query": "q"} + + +# ----------------------------------------------------------------------------- disclosure +def test_report_to_dict_discloses_filter_safe_sections_only_when_present(): + r = Report(report_id="r", simulation_id="s", graph_id="g", simulation_requirement="q", + status=ReportStatus.COMPLETED) + assert "filter_safe_sections" not in r.to_dict() + r.filter_safe_sections = {"The Actor Architecture": 0} + assert r.to_dict()["filter_safe_sections"] == {"The Actor Architecture": 0} + assert r.to_dict()["partial"] is False + + +def test_record_filter_safe_section_keeps_the_highest_level_and_tolerates_bare_agents(): + a = _bare_agent() + a._record_filter_safe_section("A", 0) + assert a._filter_safe_sections == {"A": 0} + a._record_filter_safe_section("A", 2) + a._record_filter_safe_section("A", 1) + a._record_filter_safe_section("B", 1) + assert a._filter_safe_sections == {"A": 2, "B": 1} diff --git a/backend/tests/test_report_visualizer.py b/backend/tests/test_report_visualizer.py index 6808f2e7..51ee9374 100644 --- a/backend/tests/test_report_visualizer.py +++ b/backend/tests/test_report_visualizer.py @@ -858,6 +858,59 @@ def test_html_worldstate_calendar_date_axis(tmp_path): assert "%{x|%Y-%m-%d}" not in html2 +@pytest.mark.skipif(not rv.PLOTLY_AVAILABLE, reason="plotly not installed") +def test_world_variables_html_skips_zero_seed_and_sets_chronological_category_order(tmp_path): + charts = str(tmp_path / "charts") + viz = ReportVisualizer() + world_state = { + "world": { + # one point per committed period, carried forward when nothing changed (the seed sits at + # the as-of date — here unlabelled, so "seed"); a zero seed cannot be indexed and is skipped + "committed_round": 4, + "period_labels": {"1": "2026-Q1", "2": "2026-Q2", "3": "2026-Q3", "4": "2026-Q4"}, + "variables": [ + { + "id": "indexed_ok", + "name": "Indexed ok", + "kind": "numeric", + "unit": "MW", + "history": [ + {"round": 0, "value": 100.0, "cause": "seed"}, + {"round": 4, "value": 110.0, "cause": "late"}, + ], + }, + { + "id": "out_of_order", + "name": "Out of order", + "kind": "numeric", + "unit": "%", + "history": [ + {"round": 0, "value": 100.0, "cause": "seed"}, + {"round": 3, "value": 105.0, "cause": "earlier"}, + ], + }, + { + "id": "zero_seed", + "name": "Zero seed", + "kind": "numeric", + "unit": "USD", + "history": [ + {"round": 0, "value": 0.0, "cause": "seed"}, + {"round": 4, "value": 1_000_000_000.0, "cause": "jump"}, + ], + }, + ], + } + } + rel = viz.build_world_variables_html(world_state, charts) + assert rel is not None + with open(str(tmp_path / rel), "r", encoding="utf-8") as f: + html = f.read() + assert "Zero seed" not in html + assert '"categoryarray":["seed","2026-Q1","2026-Q2","2026-Q3","2026-Q4"]' in html + assert '"x":["seed","2026-Q1","2026-Q2","2026-Q3","2026-Q4"],"y":[100.0,100.0,100.0,100.0,110.0]' in html # carried forward, indexed to 100 + + @pytest.mark.skipif(not rv.PLOTLY_AVAILABLE, reason="plotly not installed") def test_html_price_history_writes_files(tmp_path): viz = ReportVisualizer() diff --git a/backend/tests/test_world_interactions.py b/backend/tests/test_world_interactions.py new file mode 100644 index 00000000..966c2e16 --- /dev/null +++ b/backend/tests/test_world_interactions.py @@ -0,0 +1,208 @@ +"""Offers, consent and obligations in the reflexive world model (ported from the world-v1 +engine's consent rule: speech cannot create an agreement). + +Pins: a proposing move aimed at another actor opens an offer with terms; the recipient's +menu carries accept / counter / reject / defer answers bound to the offer and the generic +"respond" entries are not duplicated for it; accepting binds both parties with obligations +and pulls them closer, rejecting pushes them apart, countering opens a counter-offer the +proposer must answer, deferring keeps it open; unanswered offers lapse after the TTL; a +lapsed obligation under an agreement, or an explicit withdrawal, is a breach that costs +reputation and the relationship; everything survives a save/load round-trip and shows up +in the briefs, the digest, the arbiter prompt and the round record. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.services import world_model as wm # noqa: E402 +from app.services import world_update as wu # noqa: E402 +from app.services.agent_decision import build_decision_prompt, build_option_menu, parse_decision # noqa: E402 + + +def _world(): + w = wm.WorldModel(question="Will a deal be signed?", as_of_date="2026-09-30", scenarios=("Deal", "No deal")) + w.add_actor(wm.ActorState(0, "Ministry", agency_class="sovereign_government", capacity_per_round=3)) + w.add_actor(wm.ActorState(1, "Agency", agency_class="regulator_agency", capacity_per_round=3)) + w.add_actor(wm.ActorState(2, "Union", agency_class="labor_union", capacity_per_round=3)) + w.set_period_labels({1: "2026-10", 2: "2026-11", 3: "2026-12", 4: "2027-01", 5: "2027-02"}) + return w + + +def _decision(agent_id, option_id, *, targets=(), terms="", statement="", kind="coalition", lag=1, status="ok"): + return {"agent_id": agent_id, "option": {"id": option_id, "label": option_id.replace("_", " "), "kind": kind, + "cost": 1, "lag": lag, "reversibility": "reversible"}, + "targets": list(targets), "magnitude": 0.6, "public_statement": statement, "terms": terms, + "status": status} + + +def _round(w, n, decisions): + return wu.deterministic_round_update(w, n, {"label": w.period_labels.get(n, f"period {n}")}, decisions, [], None) + + +# ----------------------------------------------------------------------------- opening and answering +def test_proposal_opens_an_offer_the_recipient_must_answer(): + w = _world() + det = _round(w, 1, [_decision(0, "open_talks", targets=["Agency"], terms="Lift the ban in exchange for audits")]) + assert len(det["offers_opened"]) == 1 and "[i1_1]" in det["offers_opened"][0] + inter = w.interactions["i1_1"] + assert (inter.proposer, inter.recipient, inter.status, inter.expires_round) == (0, 1, "open", 3) + assert inter.terms == "Lift the ban in exchange for audits" + w.commit_round(1) + # the recipient's menu: four bound answers first, no duplicated generic "respond" entries + w.start_round(2, {"label": "2026-11"}) + menu = build_option_menu(w.actors[1], w, 2) + ids = [o["id"] for o in menu] + assert ids[:4] == ["accept:i1_1", "counter:i1_1", "reject:i1_1", "defer:i1_1"] + assert not any(i.startswith("respond_") for i in ids) + assert menu[0]["suggested_targets"] == ["Ministry"] and menu[0]["interaction_id"] == "i1_1" + assert "Lift the ban" in menu[0]["label"] and "binds both sides" in menu[0]["label"] + # the proposer's menu carries no answer entry + assert not any(":" in o["id"] for o in build_option_menu(w.actors[0], w, 2)) + # briefs + pub = w.render_public_brief(upto_round=1) + assert "## Offers and agreements" in pub and "Ministry made an offer to Agency [i1_1]" in pub + priv = w.render_private_brief(1, upto_round=1) + assert "Offers awaiting YOUR answer" in priv and "[i1_1] from Ministry: Lift the ban" in priv + assert "Your offers awaiting an answer: to Agency [i1_1]" in w.render_private_brief(0, upto_round=1) + + +def test_accept_binds_both_parties_and_moves_the_relationship(): + w = _world() + _round(w, 1, [_decision(0, "form_alliance", targets=["Agency"], terms="Joint task force by December")]) + w.commit_round(1) + det = _round(w, 2, [_decision(1, "accept:i1_1", statement="We accept the joint task force.", lag=1)]) + inter = w.interactions["i1_1"] + assert inter.status == "accepted" and inter.resolved_round == 2 and inter.response.startswith("We accept") + assert len(det["offers_answered"]) == 1 and det["offers_answered"][0].startswith("Agreement [i1_1]: Agency accepted") + for aid in (0, 1): + pend = w.actors[aid].pending_commitments() + assert len(pend) == 1 and pend[0]["source"] == "agreement:i1_1" and pend[0]["due_round"] == 3 + assert pend[0]["counterparty"] == 1 - aid and "Honour the agreement with" in pend[0]["description"] + assert set(inter.obligations) == {w.actors[0].commitments[0]["id"], w.actors[1].commitments[0]["id"]} + rel = w._relationship_between(0, 1) + assert rel is not None and rel.polarity > 0 and rel.last_changed_round == 2 + assert w.agreements_between(0, 1) == [inter] + # the agreement is visible in the digest and the record diff + digest = wu.build_round_digest(w, 2, w.snapshot(), []) + assert "Offers and agreements:" in digest and "Agreement [i1_1]" in digest + # honoured by acting when due: both act in round 3 → fulfilled, no breach + w.commit_round(2) + _round(w, 3, [_decision(0, "public_statement", kind="communication", lag=0), + _decision(1, "public_statement", kind="communication", lag=0)]) + assert all(c["status"] == "fulfilled" for a in (0, 1) for c in w.actors[a].commitments) + assert not w.events_in_round(3, ("breach",)) + + +def test_reject_counter_and_defer(): + w = _world() + _round(w, 1, [_decision(0, "open_talks", targets=["Agency"], terms="A"), + _decision(2, "bargain", targets=["Agency"], terms="B")]) + w.commit_round(1) + polarity_after_offer = w._relationship_between(0, 1).polarity # the offer itself pulled them closer + det = _round(w, 2, [_decision(1, "reject:i1_1", statement="No.")]) + assert w.interactions["i1_1"].status == "rejected" and det["offers_answered"][0].startswith("Agency rejected Ministry's offer") + assert w._relationship_between(0, 1).polarity < polarity_after_offer + # defer keeps the second offer open, then a counter opens a counter-offer back to the Union + w.commit_round(2) + det = _round(w, 3, [_decision(1, "defer:i1_2")]) + assert w.interactions["i1_2"].status == "deferred" and w.interactions["i1_2"].is_open() + assert "deferred its answer" in det["offers_answered"][0] + assert [o["id"] for o in build_option_menu(w.actors[1], w, 4)][:4] == ["accept:i1_2", "counter:i1_2", "reject:i1_2", "defer:i1_2"] + w.commit_round(3) + det = _round(w, 4, [_decision(1, "counter:i1_2", terms="B minus the clause", statement="Counter.")]) + assert w.interactions["i1_2"].status == "countered" + child = w.interactions["i4_3"] + assert (child.kind, child.proposer, child.recipient, child.parent_id, child.terms) == ("counter", 1, 2, "i1_2", "B minus the clause") + assert len(det["offers_opened"]) == 1 and "counter-offer to Union [i4_3]" in det["offers_opened"][0] + assert [o["id"] for o in build_option_menu(w.actors[2], w, 5)][0] == "accept:i4_3" + # an answer to an offer that is not open is recorded, not applied + w.commit_round(4) + _round(w, 5, [_decision(0, "accept:i1_1")]) + assert w.interactions["i1_1"].status == "rejected" + assert any("no longer open" in e.description for e in w.events_in_round(5, ("system",))) + + +def test_unanswered_offers_lapse_after_the_ttl(): + w = _world() + _round(w, 1, [_decision(0, "open_talks", targets=["Agency"], terms="T")]) + for n in (2, 3): + w.commit_round(n - 1) + det = _round(w, n, []) + assert det["offers_lapsed"] == [] and w.interactions["i1_1"].is_open() + w.commit_round(3) + det = _round(w, 4, []) + assert w.interactions["i1_1"].status == "lapsed" and det["offers_lapsed"] == ["Ministry → Agency: T"] + assert any("lapsed unanswered" in e.description for e in w.events_in_round(4, ("offer",))) + assert build_option_menu(w.actors[1], w, 4)[0]["id"].startswith(("plan_", "public_statement", "respond_")) + + +def test_lapsed_obligation_and_withdrawal_are_breaches(): + w = _world() + _round(w, 1, [_decision(0, "sign_agreement", targets=["Agency"], terms="Deliver audits in November")]) + w.commit_round(1) + _round(w, 2, [_decision(1, "accept:i1_1", lag=1)]) # obligations due round 3 + w.commit_round(2) + rep_before = w.actors[1].reputation + _round(w, 3, [_decision(0, "public_statement", kind="communication", lag=0)]) # Ministry acts, Agency holds + w.commit_round(3) + det = _round(w, 4, []) # Agency's obligation lapses → breach + assert w.interactions["i1_1"].status == "breached" + assert len(det["breaches"]) == 1 and det["breaches"][0].startswith("Breach [i1_1]: Agency broke its agreement with Ministry") + assert w.actors[1].reputation < rep_before + assert w.actors[1].commitments[0]["status"] == "breached" + assert w._relationship_between(0, 1).valence in ("adversarial", "neutral", "transactional") + # explicit withdrawal from an agreement in force + w2 = _world() + _round(w2, 1, [_decision(0, "form_alliance", targets=["Union"], terms="Pact")]) + w2.commit_round(1) + _round(w2, 2, [_decision(2, "accept:i1_1", lag=3)]) + w2.commit_round(2) + det = _round(w2, 3, [_decision(0, "break_alliance", targets=["Union"])]) + assert w2.interactions["i1_1"].status == "breached" and len(det["breaches"]) == 1 + assert w2.actors[0].commitments[0]["status"] == "breached" and w2.actors[2].commitments[0]["status"] == "released" + assert "Ministry withdrew" in det["breaches"][0] + + +# ----------------------------------------------------------------------------- contract, prompt, persistence +def test_decision_contract_carries_terms_and_bound_answers_parse(): + w = _world() + _round(w, 1, [_decision(0, "open_talks", targets=["Agency"], terms="T")]) + w.commit_round(1) + w.start_round(2, {"label": "2026-11"}) + actor = w.actors[1] + menu = build_option_menu(actor, w, 2) + prompt = build_decision_prompt(actor, world_clock="# CLOCK", public_brief=w.render_public_brief(upto_round=1), + private_brief=w.render_private_brief(1, upto_round=1), options=menu, + scenarios=w.scenarios, cast_names=["Ministry", "Agency", "Union"]) + assert '"terms": " | null"' in prompt and "binds nobody until the other side accepts" in prompt + assert "[counter:i1_1]" in prompt + d = parse_decision({"option_id": "counter:i1_1", "terms": " T minus clause 3 ", "public_statement": "We counter.", + "magnitude": 0.7}, actor=actor, world=w, options=menu, scenarios=w.scenarios, round_num=2) + assert d.ok and d.option["id"] == "counter:i1_1" and d.terms == "T minus clause 3" and d.targets == ["Ministry"] + assert d.to_dict()["terms"] == "T minus clause 3" + assert parse_decision({"option_id": "accept:i9_9"}, actor=actor, world=w, options=menu, scenarios=w.scenarios, + round_num=2).status == "invalid" + + +def test_arbiter_prompt_lists_offers_and_state_round_trips(): + w = _world() + det = _round(w, 1, [_decision(0, "open_talks", targets=["Agency"], terms="T")]) + prompt = wu.build_arbiter_prompt(w, 1, {"label": "2026-10"}, [], det, w.scenarios) + assert "## Offers and agreements (settled only by the parties' answers — never assume consent)" in prompt + assert "Ministry made an offer to Agency [i1_1]: T" in prompt and "Still open: Ministry → Agency [i1_1]: T" in prompt + w.commit_round(1) + before = w.snapshot() + _round(w, 2, [_decision(1, "accept:i1_1", lag=1)]) + diff = w.diff(before) + assert diff["interactions_changed"] == [{"id": "i1_1", "from": "open", "to": "accepted", "kind": "proposal", + "proposer": "Ministry", "recipient": "Agency", "terms": "T"}] + again = wm.WorldModel.from_dict(w.to_dict()) + assert again.interactions["i1_1"].to_dict() == w.interactions["i1_1"].to_dict() + assert again._interaction_seq == w._interaction_seq + assert again.actors[0].commitments == w.actors[0].commitments + # a fresh offer after loading continues the id sequence + again.start_round(3, {"label": "2026-12"}) + ev = again.open_interaction(3, 2, 0, "bargain", "X") + assert ev.description.startswith("Union made an offer to Ministry [i3_2]") diff --git a/backend/tests/test_world_model.py b/backend/tests/test_world_model.py new file mode 100644 index 00000000..ddbb4e9a --- /dev/null +++ b/backend/tests/test_world_model.py @@ -0,0 +1,403 @@ +"""Reflexive world-model kernel (design §2.3): pure, offline, deterministic. + +Pins: quantity parsing, seed construction, decision mechanics (capacity, commitments, +alliance nudges), bounded arbiter application (change-ratio clamp), delayed effects +with a seeded RNG, lapsed commitments, snapshot/diff, agent-facing briefs (herding +guard: no scenario shares) and JSON round-trips. +""" + +import json + +import pytest + +from app.services.world_model import ( + PendingEffect, + Relationship, + StateVariable, + WorldModel, + format_value, + parse_quantity, + slugify, +) + + +class _RNG: + """Deterministic rng stand-in: returns the scripted values in order.""" + + def __init__(self, values): + self.values = list(values) + + def random(self): + return self.values.pop(0) if self.values else 0.0 + + +def _seed(): + return { + "schema_version": "world-model-seed/v1", + "question": "Will the grid permit unlock deployment by 2027?", + "as_of_date": "2026-07-01", + "horizon_date": "2027-03-31", + "scenarios": ["Permit granted", "Permit delayed"], + "state_variables": [ + {"name": "Alpha planned grid investment", "value": "US$2 billion", "as_of": "2026-07-01", + "provenance": "research"}, + {"name": "Permit status", "value": "pending", "kind": "categorical", "provenance": "research"}, + {"name": "Regional capacity", "value": 120, "unit": "MW", "provenance": "official"}, + ], + "actors": [ + {"agent_id": 0, "name": "Alpha Systems", "agency_class": "firm", "capacity_per_round": 2, + "documented_plans": ["Build the Aurora line by 2027"], "red_lines": ["No unfinanced mandate"], + "aliases": ["Alpha"], "outcome_power": 1.5}, + {"agent_id": 1, "name": "Beta Union", "agency_class": "labor_union", "capacity_per_round": 2}, + {"agent_id": 2, "name": "Grid Regulator", "agency_class": "regulator_agency", "capacity_per_round": 2}, + ], + "relationships": [ + {"source": "Alpha Systems", "target": "Beta Union", "type": "OPPOSES", "valence": "adversarial", + "strength": 0.8, "basis": "permit timetable"}, + ], + "settings": {"max_change_ratio": 0.5}, + } + + +def _agent_configs(): + return [ + {"agent_id": 0, "entity_name": "Alpha Systems", "stance": "supportive", "gains_if": "timetable holds"}, + {"agent_id": 1, "entity_name": "Beta Union", "stance": "opposing"}, + {"agent_id": 2, "entity_name": "Grid Regulator", "stance": "neutral"}, + {"agent_id": 3, "entity_name": "Local Grid Co", "stance": "neutral"}, + ] + + +def _decision(agent_id, name, option, *, targets=(), magnitude=0.8, statement="", commitment=None, + alliance_moves=(), status="ok"): + return { + "agent_id": agent_id, "actor_name": name, "option": option, "targets": list(targets), + "magnitude": magnitude, "public_statement": statement, "commitment": commitment, + "alliance_moves": list(alliance_moves), "status": status, + } + + +# ----------------------------------------------------------------------------- parsing +@pytest.mark.parametrize("raw, number, unit", [ + ("US$2 billion", 2e9, "US$"), + ("25%", 25.0, "%"), + ("1,200 GW", 1200.0, "GW"), + ("约 3.5 万亿", 3.5e12, ""), + ("12.5 percent", 12.5, "%"), + (7, 7.0, ""), + ("pending", None, ""), + (None, None, ""), + (True, None, ""), +]) +def test_parse_quantity_table(raw, number, unit): + got_number, got_unit = parse_quantity(raw) + assert got_number == number + assert got_unit == unit + + +def test_slugify_stable_and_cjk_safe(): + assert slugify("US tariff rate on Chinese EVs (%)") == "us_tariff_rate_on_chinese_evs" + assert slugify("关税 税率") == "关税_税率" + assert slugify("") .startswith("var_") + assert slugify("A" * 200) == "a" * 64 + + +def test_format_value_units(): + assert format_value(2e9, "US$") == "US$2.00 billion" + assert format_value(25.0, "%") == "25%" + assert format_value(1200.0, "GW") == "1200 GW" + assert format_value(25000.0, "GW") == "25,000 GW" + assert format_value(3.5e12, "CNY") == "CNY3.50 trillion" + assert format_value("pending", "") == "pending" + assert format_value(True, "") == "yes" + + +# ----------------------------------------------------------------------------- seed +def test_from_seed_builds_actors_variables_relationships(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + assert set(world.actors) == {0, 1, 2, 3} # cast members without a seed row still exist + assert world.actors[3].agency_class == "other_actor" + assert world.actors[0].outcome_power == 1.5 and world.actors[1].outcome_power is None + assert world.actors[0].incentives == {"gains_if": "timetable holds"} # seed row has none → cfg fallback + assert world.actors[3].incentives == {} + inv = world.variables["alpha_planned_grid_investment"] + assert inv.kind == "numeric" and inv.value == 2e9 and inv.unit == "US$" + assert world.variables["permit_status"].kind == "categorical" + assert world.variables["regional_capacity"].unit == "MW" + rel = world.relationships[(0, 1)] + assert rel.valence == "adversarial" and rel.polarity < 0 + assert world.resolve_actor("alpha") == 0 # alias + assert world.resolve_actor("beta union") == 1 + assert world.resolve_actor("Nobody") is None + assert world.scenarios == ["Permit granted", "Permit delayed"] + + +def test_cfg_incentives_used_when_seed_has_none(): + seed = _seed() + seed["actors"] = [] + world = WorldModel.from_seed(seed, _agent_configs()) + assert world.actors[0].incentives == {"gains_if": "timetable holds"} + assert world.actors[0].posture == "supportive" + + +# ----------------------------------------------------------------------------- decisions +def test_register_decisions_consumes_capacity_commits_and_nudges(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, {"label": "2026-Q3", "period_start": "2026-07-02", "period_end": "2026-09-30"}) + invest = {"id": "invest_capex", "label": "Commit capital", "kind": "economic", "cost": 3, "lag": 2, + "reversibility": "sticky"} + ally = {"id": "form_alliance", "label": "Form alliance", "kind": "coalition", "cost": 2, "lag": 1} + events = world.register_decisions(1, [ + _decision(0, "Alpha Systems", invest, statement="We commit $2bn to Aurora.", + commitment={"description": "Break ground on Aurora", "due_periods": 2}), + _decision(2, "Grid Regulator", ally, targets=["Local Grid Co"], + alliance_moves=[{"target": "Local Grid Co", "move": "propose"}]), + _decision(1, "Beta Union", {"id": "hold_position", "label": "Hold", "kind": "wait", "cost": 0}), + _decision(3, "Local Grid Co", {"id": "x", "label": "x", "kind": "communication"}, status="failed"), + ]) + kinds = [e.kind for e in events] + assert kinds.count("action") == 4 + alpha = world.actors[0] + assert alpha.capacity_used == 2 # capped at capacity_per_round + assert alpha.capacity_remaining == 0 + assert alpha.last_action["option_id"] == "invest_capex" + assert [c["description"] for c in alpha.pending_commitments()] == ["Break ground on Aurora"] + assert alpha.pending_commitments()[0]["due_round"] == 3 + # coalition move created a relationship and pulled it closer + rel = world._relationship_between(2, 3) + assert rel is not None and rel.polarity > 0 + # hold and failed decisions record events but no commitments + assert world.actors[1].pending_commitments() == [] + assert world.actors[3].pending_commitments() == [] + assert any("no recorded move (failed)" in e.description for e in events) + # statement preserved on the action event + assert any(e.statement.startswith("We commit") for e in events) + + +def test_alliance_break_moves_relationship_apart(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, None) + before = world.relationships[(0, 1)].polarity + world.register_decisions(1, [_decision(0, "Alpha Systems", {"id": "break_alliance", "label": "Break", + "kind": "coalition", "cost": 2}, + alliance_moves=[{"target": "Beta Union", "move": "break"}])]) + assert world.relationships[(0, 1)].polarity < before + + +# ----------------------------------------------------------------------------- arbiter application +def test_apply_arbiter_update_clamps_and_records(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, {"label": "2026-Q3", "period_end": "2026-09-30"}) + summary = world.apply_arbiter_update(1, { + "variable_changes": [ + {"variable_id": "regional_capacity", "new_value": 1000, "rationale": "Aurora announced"}, + {"variable_id": "permit_status", "new_value": "under review", "rationale": "inquiry"}, + {"variable_id": "does_not_exist", "new_value": 1}, + ], + "new_variables": [{"name": "Union strike risk", "value": "elevated", "kind": "categorical", + "rationale": "Strike rhetoric escalated"}], + "events": [{"description": "Markets react to the Aurora pledge", "kind": "emergent", + "actors": ["Alpha Systems"]}], + "relationship_changes": [{"source": "Alpha Systems", "target": "Grid Regulator", "direction": "closer"}], + "pending_effects": [{"description": "Permit decision lands", "due_periods": 1, + "variable_id": "permit_status", "new_value": "granted", "probability": 0.5}], + "action_resolutions": [{"actor": "Alpha Systems", "outcome": "partial", "rationale": "financing open"}], + }) + cap = world.variables["regional_capacity"] + assert cap.value == 180.0 # 120 → clamped to +50% + assert cap.history[-1]["cause"] == "Aurora announced" and cap.history[-1]["source"] == "arbiter" + assert world.variables["permit_status"].value == "under review" + assert [c["id"] for c in summary["variables_changed"]] == ["regional_capacity", "permit_status"] + assert summary["variables_added"][0]["id"] == "union_strike_risk" + added = world.variables["union_strike_risk"] + assert added.history[0]["round"] == 1 and added.history[0]["cause"] == "Strike rhetoric escalated" + assert summary["events"] == 1 and summary["effects_added"] == 1 and summary["resolutions"] == 1 + assert world._relationship_between(0, 2).polarity > 0 + assert world.pending_effects[0].due_round == 2 + assert world.actors[0].last_action is None # no decision registered this round + + +def test_apply_arbiter_update_no_clamp_when_ratio_disabled(): + seed = _seed() + seed["settings"] = {"max_change_ratio": None} + world = WorldModel.from_seed(seed, _agent_configs()) + world.start_round(1, None) + world.apply_arbiter_update(1, {"variable_changes": [{"variable_id": "regional_capacity", "new_value": 1000}]}) + assert world.variables["regional_capacity"].value == 1000.0 + + +# ----------------------------------------------------------------------------- delayed effects & commitments +def test_fire_due_effects_rolls_probability_with_seeded_rng(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, None) + world.pending_effects.append(PendingEffect("fx1", due_round=2, description="Permit granted", + variable_id="permit_status", op="set", value="granted", + probability=0.5, source="arbiter")) + world.pending_effects.append(PendingEffect("fx2", due_round=2, description="Capacity add", + variable_id="regional_capacity", op="add", value=30, + probability=0.5, source="arbiter")) + world.pending_effects.append(PendingEffect("fx3", due_round=5, description="Later", probability=1.0)) + assert world.fire_due_effects(1, _RNG([0.9, 0.9])) == [] # nothing due yet + world.start_round(2, {"label": "2026-Q4"}) + fired = world.fire_due_effects(2, _RNG([0.1, 0.9])) # first happens, second does not + assert [f["happened"] for f in fired] == [True, False] + assert world.variables["permit_status"].value == "granted" + assert world.variables["regional_capacity"].value == 120.0 + assert [fx.id for fx in world.pending_effects] == ["fx3"] + descs = [e.description for e in world.events_in_round(2, ("consequence",))] + assert "Permit granted" in descs and any(d.startswith("Did not materialise") for d in descs) + + +def test_commitments_become_due_then_lapse(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, None) + world.actors[0].add_commitment("Break ground", made_round=1, due_round=2) + world.start_round(2, None) + assert world.actors[0].commitments[0]["status"] == "due" + assert world.lapse_overdue_commitments(2) == [] + world.start_round(3, None) + lapsed = world.lapse_overdue_commitments(3) + assert lapsed and lapsed[0]["status"] == "lapsed" + assert any("lapsed" in e.description for e in world.events_in_round(3)) + + +def test_due_commitment_is_honoured_by_acting_not_by_resolution(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.set_period_labels({1: "2026-Q3", 2: "2026-Q4"}) + world.start_round(1, None) + world.actors[0].add_commitment("Break ground", made_round=1, due_round=2) + world.apply_arbiter_update(1, {"action_resolutions": [{"actor": "Alpha Systems", "outcome": "success"}]}) + assert world.actors[0].commitments[0]["status"] == "pending" # success of the move ≠ delivery of the pledge + world.start_round(2, None) + assert "due 2026-Q4" in world.render_public_brief(upto_round=1) + world.register_decisions(2, [_decision(0, "Alpha Systems", {"id": "invest_capex", "label": "Commit capital", + "kind": "economic", "cost": 3, "lag": 0})]) + assert world.actors[0].commitments[0]["status"] == "fulfilled" + assert world.actors[0].commitments[0]["fulfilled_round"] == 2 + + +# ----------------------------------------------------------------------------- snapshot / diff / briefs +def test_diff_reports_changed_added_and_relationship_moves(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, None) + before = world.snapshot() + world.apply_arbiter_update(1, { + "variable_changes": [{"variable_id": "regional_capacity", "new_value": 150}], + "new_variables": [{"name": "Permit filings", "value": 3}], + "relationship_changes": [{"source": "Alpha Systems", "target": "Beta Union", "direction": "closer"}], + }) + diff = world.diff(before) + assert diff["variables_changed"][0]["id"] == "regional_capacity" and diff["variables_changed"][0]["to"] == 150.0 + assert diff["variables_added"][0]["name"] == "Permit filings" + assert diff["relationships_changed"][0]["direction"] == "closer" + + +def test_public_brief_shows_facts_moves_consequences_and_no_scenario_shares(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.scenario_shares = {"Permit granted": 0.63, "Permit delayed": 0.37} + world.start_round(1, {"label": "2026-Q3", "period_start": "2026-07-02", "period_end": "2026-09-30"}) + before = world.snapshot() + world.register_scheduled_events(1, [{"date": "2026-09-15", "content": "[2026-09-15] Permit hearing held"}]) + world.register_decisions(1, [_decision(0, "Alpha Systems", {"id": "invest_capex", "label": "Commit capital", + "kind": "economic", "cost": 3, "lag": 2}, + statement="We commit to Aurora now.", + commitment={"description": "Break ground", "due_periods": 2})]) + world.apply_arbiter_update(1, {"variable_changes": [{"variable_id": "regional_capacity", "new_value": 150}]}) + world.commit_round(1) + brief = world.render_public_brief(upto_round=1, previous=before) + assert brief.startswith("# WORLD STATE — as of 2026-09-30 (after 2026-Q3)") + assert "## Key facts" in brief and "Regional capacity: 150 MW (was 120 MW last period)" in brief + assert "## Moves last period" in brief and 'Alpha Systems: Commit capital — "We commit to Aurora now."' in brief + assert "## Consequences and events" in brief and "Permit hearing held" in brief + assert "## Pending and announced" in brief and "Break ground" in brief + # herding guard: the scenario distribution never reaches agent-facing text + assert "63" not in brief and "37" not in brief and "%" not in brief + assert "Permit granted" not in brief and "Permit delayed" not in brief + + +def test_private_brief_lists_own_commitments_and_moves_aimed_at_actor(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, {"label": "2026-Q3", "period_end": "2026-09-30"}) + world.register_decisions(1, [ + _decision(1, "Beta Union", {"id": "strike_action", "label": "Call a strike", "kind": "coercive", + "cost": 3, "lag": 0}, targets=["Alpha Systems"]), + _decision(0, "Alpha Systems", {"id": "invest_capex", "label": "Commit capital", "kind": "economic", + "cost": 3, "lag": 2}, commitment={"description": "Break ground", "due_periods": 1}), + ]) + world.commit_round(1) + world.start_round(2, {"label": "2026-Q4", "period_end": "2026-12-31"}) + note = world.render_private_brief(0, upto_round=1) + assert note.startswith("# YOUR SITUATION — Alpha Systems") + assert "Capacity this period: 2/2" in note + assert "Break ground" in note and "(DUE NOW)" in note + assert "Aimed at you last period" in note and "Beta Union: Call a strike" in note + assert "Rivals / opponents: Beta Union" in note + assert "Your red lines: No unfinanced mandate" in note + assert world.render_private_brief(99) == "" + + +def test_brief_respects_char_budget_on_line_boundaries(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, None) + brief = world.render_public_brief(upto_round=0, previous=None, max_chars=80) + assert len(brief) <= 80 + assert not brief.endswith("…") or "\n" not in brief + + +# ----------------------------------------------------------------------------- persistence +def test_json_round_trip_preserves_state(): + world = WorldModel.from_seed(_seed(), _agent_configs()) + world.start_round(1, {"label": "2026-Q3", "period_end": "2026-09-30"}) + world.register_decisions(1, [ + _decision(0, "Alpha Systems", {"id": "invest_capex", "label": "Commit capital", + "kind": "economic", "cost": 3, "lag": 2}, + commitment={"description": "Break ground", "due_periods": 1}), + _decision(0, "Alpha Systems", {"id": "product_launch", "label": "Launch", "kind": "operational", + "cost": 2, "reversibility": "irreversible"}), + ]) + world.apply_arbiter_update(1, {"pending_effects": [{"description": "Permit lands", "due_periods": 1}], + "events": [{"description": "Reaction", "kind": "emergent"}]}) + world.commit_round(1) + world.scenario_shares = {"Permit granted": 0.6, "Permit delayed": 0.4} + payload = json.loads(json.dumps(world.to_dict(), ensure_ascii=False)) + clone = WorldModel.from_dict(payload) + assert clone.snapshot() == world.snapshot() + assert clone.committed_round == 1 and clone.round == 1 + assert clone.actors[0].pending_commitments()[0]["description"] == "Break ground" + assert [fx.description for fx in clone.pending_effects] == ["Permit lands"] + assert [e.description for e in clone.events] == [e.description for e in world.events] + assert clone.scenario_shares == world.scenario_shares + assert clone.resolve_actor("Alpha") == 0 + assert "product_launch" in clone.actors[0].irreversible_option_ids + assert clone.to_dict() == world.to_dict() + + +def test_state_variable_never_keeps_a_kind_name_as_unit(): + """Arbiter-created facts of the live run carried unit='boolean' / 'text' / 'date' (the kind + echoed into the unit field) and rendered as "yes boolean"; a unit belongs to numeric facts + only, and a kind name is never a unit — in the kernel, on load and in format_value.""" + assert StateVariable("t", "Talks opened", True, unit="boolean", kind="boolean").unit == "" + assert StateVariable("d", "Deadline", "2027-03-10", unit="date", kind="text").unit == "" + assert StateVariable("n", "Backlog", 12, unit="numeric", kind="numeric").unit == "" + assert StateVariable("p", "Tariff", 10, unit="%", kind="numeric").unit == "%" + var = StateVariable("c", "Capacity", 120, unit="MW", kind="numeric") + assert var.set_value("suspended", round_num=1, source="arbiter", cause="test") is True + assert var.kind == "text" and var.unit == "" + loaded = StateVariable.from_dict({"id": "t", "name": "Talks opened", "value": True, "unit": "boolean", + "kind": "boolean"}) + assert loaded.unit == "" and loaded.value is True + assert format_value(True, "boolean") == "yes" + assert format_value("2027-03-10", "date") == "2027-03-10" + assert format_value(12.0, "numeric") == "12" + assert format_value(12.0, "MW") == "12 MW" + + +def test_state_variable_and_relationship_dicts(): + var = StateVariable("x", "X", "12%", kind="numeric") + assert var.value == 12.0 and var.unit == "" and var.kind == "numeric" + assert var.set_value(15, round_num=2, source="arbiter", cause="test") is True + assert var.set_value(15, round_num=3, source="arbiter", cause="noop") is False + rel = Relationship(0, 1, kind="ALLY_OF", valence="allied") + rel.nudge(-0.9, round_num=1, cause="betrayal") + assert rel.valence == "adversarial" and rel.polarity == -0.1 or rel.polarity < 0 + assert Relationship.from_dict(rel.to_dict()).to_dict() == rel.to_dict() diff --git a/backend/tests/test_world_model_downstream.py b/backend/tests/test_world_model_downstream.py new file mode 100644 index 00000000..c2a65616 --- /dev/null +++ b/backend/tests/test_world_model_downstream.py @@ -0,0 +1,422 @@ +"""Downstream consumers of the reflexive world (design §2.9, acceptance A9). + +Offline. Pins: the world-state API (containment, 404 before commit, bounded happy path), +run_summary's ``world_model`` block, the run-state projection of ``world_update`` rows, +the report's world-evolution signal block, the world-variables chart builder and the +orchestrator's decision-channel summary. +""" + +import json +import os + +import pytest + +from app.config import Config +from app.services.simulation_runner import SimulationRunner + + +def _world_state_payload(committed=3): + return { + "schema_version": "world-state-file/v1", + "simulation_id": "sim_ws", + "committed_round": committed, + "brief": "# WORLD STATE — as of 2027-03-31\n## Key facts\n- Tariff rate: 25%", + "digest": "Tariff shock ripples.", + "headline": "2027-Q1: 3 decisions · 1 facts changed · arbiter=applied", + "summary": {"schema_version": "world-model-summary/v1", "rounds_committed": committed, "variables": 2, + "actors": 3, "relationships": 1, "events": 5, "pending_effects": 1, + "arbiter_enabled": True, "arbiter_failed_rounds": 1, "decision_actors_max": 24}, + "world": { + "schema_version": "world-model-state/v1", "question": "Will A happen?", "as_of_date": "2026-07-11", + "horizon_date": "2027-03-31", "scenarios": ["A", "B"], "round": committed, "committed_round": committed, + "period": {"label": "2027-Q1", "period_end": "2027-03-31"}, + "period_labels": {"1": "2026-Q3", "2": "2026-Q4", "3": "2027-Q1"}, + "variables": [ + {"id": "tariff_rate", "name": "Tariff rate", "value": 25.0, "unit": "%", "kind": "numeric", + "provenance": "arbiter", "as_of": "2026-07-01", + "history": [{"round": 0, "value": 10.0, "cause": "seed", "source": "research"}, + {"round": 1, "value": 25.0, "cause": "decree", "source": "arbiter"}, + {"round": 2, "value": 25.0, "cause": "hold", "source": "arbiter"}]}, + {"id": "permit", "name": "Permit", "value": "pending", "unit": "", "kind": "categorical", + "provenance": "research", "history": [{"round": 0, "value": "pending", "cause": "seed", "source": "research"}]}, + ], + "actors": [ + {"agent_id": 0, "name": "Actor0", "agency_class": "sovereign_government", "archetype": "actor", + "capacity_per_round": 3, "capacity_used": 3, "reputation": 0.52, + "commitments": [{"id": "c0", "description": "Publish schedule", "due_round": 4, "status": "pending"}, + {"id": "c1", "description": "Old", "due_round": 1, "status": "fulfilled"}], + "last_action": {"round": 3, "option_id": "impose_measure", "label": "Impose tariff", "resolution": "success"}}, + {"agent_id": 1, "name": "Actor1", "agency_class": "firm", "commitments": [], "last_action": None}, + ], + "relationships": [{"source": 0, "target": 1, "kind": "REGULATES", "valence": "adversarial", + "polarity": -0.3, "strength": 0.6, "last_changed_round": 3, "history": []}], + "pending_effects": [{"id": "fx1", "due_round": 4, "description": "Importers relocate", "probability": 0.5}], + "events": [{"round": r, "kind": "action", "description": f"Actor0 move {r}", "actors": ["Actor0"], + "targets": ["Actor1"], "statement": "Tariff raised"} for r in (1, 2, 3)], + "interactions": [ + {"id": "i1_1", "kind": "proposal", "proposer": 0, "recipient": 1, "option_id": "open_talks", + "terms": "Audits for licences", "opened_round": 1, "expires_round": 3, "status": "accepted", + "parent_id": "", "resolved_round": 2, "response": "Agreed.", "obligations": ["c0", "c9"], "deferrals": 0}, + {"id": "i3_2", "kind": "counter", "proposer": 1, "recipient": 0, "option_id": "partnership_deal", + "terms": "Joint venture", "opened_round": 3, "expires_round": 5, "status": "open", + "parent_id": "", "resolved_round": None, "response": "", "obligations": [], "deferrals": 1}, + "junk", {"status": "lapsed"}, + ], + }, + } + + +def _history_rows(): + return [ + {"round": 1, "period_label": "2026-Q3", "headline": "2026-Q3: 3 decisions", "narrative": "Shock.", + "arbiter_status": "applied", "decisions": {"total": 3, "ok": 3, "held": 2}, + "deterministic": {"actions": ["Impose or raise a tariff → Actor1", "Actor1 holds position"], + "scheduled_events": [], "commitments_lapsed": [], "effects_fired": []}, + "diff": {"variables_changed": [{"id": "tariff_rate", "name": "Tariff rate", "from": 10.0, "to": 25.0, "unit": "%"}], + "variables_added": [], "relationships_changed": [{"source": "Actor0", "target": "Actor1", "from": "directional", + "to": "adversarial", "direction": "farther"}]}, + "scenario_pressure": {"A": "up"}}, + {"round": 2, "period_label": "2026-Q4", "headline": "2026-Q4: quiet", "narrative": "", + "arbiter_status": "failed", "arbiter_problems": ["llm error: BadRequestError"], + "error": "BadRequestError: content filter", + "decisions": {"total": 3}, "deterministic": {"actions": []}, "diff": {}, "shares": {"A": 0.6, "B": 0.4}}, + {"round": 3, "period_label": "2027-Q1", "headline": "2027-Q1: 3 decisions", "narrative": "Importers protest.", + "arbiter_status": "applied", "arbiter_problems": ["unknown variable 'x'"], + "decisions": {"total": 3}, "deterministic": {"actions": ["Open an inquiry → Actor0"]}, + "diff": {"variables_changed": [], "variables_added": [], "relationships_changed": []}}, + ] + + +def _digest_rows(): + # one audit row per stepped round (round 2 has none: the shares step was isolated that round), + # plus a duplicate for round 3 (the later row wins) and junk lines the reader must skip + return [ + {"round": 1, "period_end": "2026-09-30", "digest": "Digest 1.", "shares": {"A": 0.5, "B": 0.5}}, + {"round": 3, "period_end": "2027-03-31", "digest": "Stale digest 3.", "shares": {"A": 0.7, "B": 0.3}}, + {"round": 3, "period_end": "2027-03-31", "digest": "Digest 3.\nMoves:\n- Actor0: Open an inquiry", "shares": {"A": 0.7, "B": 0.3}}, + {"round": 9, "digest": "Digest of a round outside the slice."}, + ] + + +def _write_world(sim_dir, with_history=True, committed=3, with_digest=False): + os.makedirs(sim_dir, exist_ok=True) + with open(os.path.join(sim_dir, "world_state.json"), "w", encoding="utf-8") as f: + json.dump(_world_state_payload(committed), f, ensure_ascii=False) + if with_history: + with open(os.path.join(sim_dir, "world_state_history.jsonl"), "w", encoding="utf-8") as f: + for row in _history_rows(): + f.write(json.dumps(row, ensure_ascii=False) + "\n") + if with_digest: + with open(os.path.join(sim_dir, "world_digest.jsonl"), "w", encoding="utf-8") as f: + f.write("{not json\n\n[1, 2]\n") + for row in _digest_rows(): + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +# ----------------------------------------------------------------------------- API +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + from app import create_app + app = create_app() + app.config["TESTING"] = True + return app.test_client() + + +def test_world_state_api_happy_path_is_bounded(client, tmp_path): + _write_world(str(tmp_path / "sim_ws")) + res = client.get("/api/simulation/sim_ws/world-state?rows=2") + assert res.status_code == 200 + data = res.get_json()["data"] + assert data["committed_round"] == 3 and data["headline"].startswith("2027-Q1") + assert data["brief"].startswith("# WORLD STATE") and data["summary"]["arbiter_failed_rounds"] == 1 + assert [v["id"] for v in data["variables"]] == ["tariff_rate", "permit"] + assert len(data["variables"][0]["history"]) == 3 + assert data["actors"][0]["pending_commitments"] == [ + {"id": "c0", "description": "Publish schedule", "due_round": 4, "status": "pending"}] + assert data["actors"][0]["last_action"]["option_id"] == "impose_measure" + assert data["relationships"][0]["valence"] == "adversarial" and "history" not in data["relationships"][0] + assert [h["round"] for h in data["history"]] == [2, 3] # last N rows only + assert data["history"][1]["narrative"] == "Importers protest." + assert len(data["recent_events"]) == 3 and data["pending_effects"][0]["id"] == "fx1" + assert data["period"]["label"] == "2027-Q1" and data["question"] == "Will A happen?" + # OFFERS: open first, names resolved, bounded fields, junk rows skipped + assert [(it["id"], it["status"], it["proposer"], it["recipient"]) for it in data["interactions"]] == [ + ("i3_2", "open", "Actor1", "Actor0"), ("i1_1", "accepted", "Actor0", "Actor1")] + assert data["interactions"][1]["obligations"] == 2 and data["interactions"][1]["response"] == "Agreed." + assert data["interactions"][0]["expires_round"] == 5 and data["interactions"][0]["kind"] == "counter" + + +def test_world_state_api_404_before_commit_and_containment(client, tmp_path): + os.makedirs(str(tmp_path / "sim_empty")) + assert client.get("/api/simulation/sim_empty/world-state").status_code == 404 + assert client.get("/api/simulation/nope/world-state").status_code == 404 + assert client.get("/api/simulation/..%2F..%2Fetc/world-state").status_code == 404 + assert client.get("/api/simulation/%2E%2E/world-state").status_code == 404 + _write_world(str(tmp_path / "real")) + os.symlink(str(tmp_path / "real" / "world_state.json"), str(tmp_path / "sim_link_target")) + os.makedirs(str(tmp_path / "sim_link")) + os.symlink(str(tmp_path / "real" / "world_state.json"), str(tmp_path / "sim_link" / "world_state.json")) + assert client.get("/api/simulation/sim_link/world-state").status_code == 404 + + +def test_world_state_api_bad_json_and_bad_rows(client, tmp_path): + sim_dir = tmp_path / "sim_bad" + sim_dir.mkdir() + (sim_dir / "world_state.json").write_text("{not json", encoding="utf-8") + res = client.get("/api/simulation/sim_bad/world-state") + assert res.status_code == 500 and "traceback" not in res.get_json() + _write_world(str(tmp_path / "sim_ok"), with_history=False) + res = client.get("/api/simulation/sim_ok/world-state?rows=zzz") + assert res.status_code == 200 and res.get_json()["data"]["history"] == [] + + +def test_world_state_api_history_rows_carry_problems_and_digests(client, tmp_path): + """The panel's round selector needs each committed round's arbiter problems, the update + error (when the update itself failed) and the agent-facing digest of that round.""" + _write_world(str(tmp_path / "sim_hist"), with_digest=True) + data = client.get("/api/simulation/sim_hist/world-state?rows=2").get_json()["data"] + rows = {h["round"]: h for h in data["history"]} + assert list(rows) == [2, 3] + assert rows[2]["arbiter_status"] == "failed" and rows[2]["arbiter_problems"] == ["llm error: BadRequestError"] + assert rows[2]["error"] == "BadRequestError: content filter" + assert rows[3]["arbiter_problems"] == ["unknown variable 'x'"] and "error" not in rows[3] + assert rows[2]["digest"] == "" # no digest row → empty, never missing + assert rows[3]["digest"] == "Digest 3.\nMoves:\n- Actor0: Open an inquiry" # the later duplicate wins + assert "shares" not in rows[2] and "shares" not in rows[3] # quantitative shares never travel + assert data["digest"] == "Tariff shock ripples." # top-level latest digest unchanged + # rows without a problems list (older runs) → [] + _write_world(str(tmp_path / "sim_plain")) + plain = client.get("/api/simulation/sim_plain/world-state").get_json()["data"]["history"] + assert [h["arbiter_problems"] for h in plain][0] == [] and all(h["digest"] == "" for h in plain) + # bounds: at most 12 problems of 200 chars, digest text capped at 2000 chars, error at 200 + sim_big = tmp_path / "sim_big" + _write_world(str(sim_big), with_history=False) + with open(sim_big / "world_state_history.jsonl", "w", encoding="utf-8") as f: + f.write(json.dumps({"round": 1, "arbiter_status": "invalid", "arbiter_problems": ["p" * 500] * 20, + "error": "e" * 500, "diff": {}}) + "\n") + with open(sim_big / "world_digest.jsonl", "w", encoding="utf-8") as f: + f.write(json.dumps({"round": 1, "digest": "d" * 5000}) + "\n") + big = client.get("/api/simulation/sim_big/world-state").get_json()["data"]["history"][0] + assert len(big["arbiter_problems"]) == 12 and all(len(p) == 200 for p in big["arbiter_problems"]) + assert len(big["error"]) == 200 and len(big["digest"]) == 2000 + # a non-list problems field is tolerated; a symlinked digest file is refused like the other artifacts + sim_link = tmp_path / "sim_dlink" + _write_world(str(sim_link), with_history=False) + with open(sim_link / "world_state_history.jsonl", "w", encoding="utf-8") as f: + f.write(json.dumps({"round": 1, "arbiter_status": "applied", "arbiter_problems": "oops", "diff": {}}) + "\n") + os.symlink(str(sim_big / "world_digest.jsonl"), str(sim_link / "world_digest.jsonl")) + linked = client.get("/api/simulation/sim_dlink/world-state").get_json()["data"]["history"][0] + assert linked["arbiter_problems"] == [] and linked["digest"] == "" + + +# ----------------------------------------------------------------------------- run summary + run state +@pytest.fixture +def sim_env(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + monkeypatch.setattr(SimulationRunner, "_run_states", {}) + monkeypatch.setattr(SimulationRunner, "_run_state_last_save", {}) + monkeypatch.setattr(SimulationRunner, "_processes", {}) + monkeypatch.setattr(SimulationRunner, "_action_queues", {}) + monkeypatch.setattr(SimulationRunner, "_monitor_threads", {}) + monkeypatch.setattr(SimulationRunner, "_stdout_files", {}) + return tmp_path + + +def _write_actions(sim_dir, rows): + os.makedirs(os.path.join(sim_dir, "twitter"), exist_ok=True) + with open(os.path.join(sim_dir, "twitter", "actions.jsonl"), "w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def test_run_summary_carries_world_model_block(sim_env): + sim_id = "sim_summary_world" + sim_dir = str(sim_env / sim_id) + _write_actions(sim_dir, [ + {"round": 1, "timestamp": "t", "event_type": "round_start", "simulated_hour": 0}, + {"round": 1, "timestamp": "t", "agent_id": 0, "agent_name": "Actor0", "action_type": "CREATE_POST", + "action_args": {"content": "x", "is_world_decision": True}, "result": None, "success": True}, + {"round": 1, "timestamp": "t", "event_type": "world_update", "headline": "2026-Q3: 1 decisions", + "period_label": "2026-Q3", "arbiter_status": "applied"}, + {"round": 1, "timestamp": "t", "event_type": "round_end", "actions_count": 1, "simulated_hours": 1.0}, + ]) + with open(os.path.join(sim_dir, "simulation_config.json"), "w", encoding="utf-8") as f: + json.dump({"time_config": {"total_simulation_hours": 1, "minutes_per_round": 60}}, f) + with open(os.path.join(sim_dir, "run_state.json"), "w", encoding="utf-8") as f: + json.dump({"current_round": 1, "total_rounds": 1}, f) + _write_world(sim_dir, with_history=False, committed=1) + summary = SimulationRunner.write_run_summary(sim_id) + assert summary["world_model"]["rounds_committed"] == 1 and summary["world_model"]["committed_round"] == 1 + assert summary["world_model"]["headline"].startswith("2027-Q1") + assert summary["world_model"]["arbiter_failed_rounds"] == 1 + assert summary["organic_action_count"] == 1 # the published statement counts + # legacy run without the file → no key + sim2 = str(sim_env / "sim_legacy") + _write_actions(sim2, [{"round": 1, "timestamp": "t", "event_type": "round_end", "actions_count": 0}]) + with open(os.path.join(sim2, "run_state.json"), "w", encoding="utf-8") as f: + json.dump({"current_round": 1, "total_rounds": 1}, f) + assert "world_model" not in SimulationRunner.write_run_summary("sim_legacy") + + +def test_action_log_projects_latest_world_update_into_run_state(sim_env): + from app.services.simulation_runner import SimulationRunState + sim_id = "sim_proj" + sim_dir = str(sim_env / sim_id) + _write_actions(sim_dir, [ + {"round": 1, "timestamp": "t", "event_type": "world_update", "headline": "r1", "period_label": "2026-Q3", + "arbiter_status": "applied", "decisions": 3, "facts_changed": 1}, + {"round": 2, "timestamp": "t", "event_type": "world_update", "headline": "r2", "period_label": "2026-Q4", + "arbiter_status": "failed", "decisions": 3, "facts_changed": 0}, + {"round": 1, "timestamp": "t", "event_type": "world_update", "headline": "stale duplicate"}, + ]) + state = SimulationRunState(simulation_id=sim_id) + SimulationRunner._read_action_log(os.path.join(sim_dir, "twitter", "actions.jsonl"), 0, state, "twitter") + wm = state.options["world_model"] + assert wm["round"] == 2 and wm["headline"] == "r2" and wm["arbiter_status"] == "failed" + assert wm["period_label"] == "2026-Q4" and wm["decisions"] == 3 + assert state.to_dict()["options"]["world_model"]["round"] == 2 + + +def test_stale_world_artifacts_are_rotated_on_rerun(): + stale = set(SimulationRunner._STALE_DERIVED_ARTIFACTS) + assert {"world_state.json", "world_state_history.jsonl", "world_actions.jsonl", "world_digest.jsonl"} <= stale + + +# ----------------------------------------------------------------------------- report block +def _bare_report_agent(sim_id): + from app.services.report_agent import ReportAgent + a = ReportAgent.__new__(ReportAgent) + a.simulation_id = sim_id + a.graph_id = "g" + a.base_simulation_id = None + return a + + +def test_world_evolution_block_renders_and_is_labeled(tmp_path, monkeypatch): + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + _write_world(str(tmp_path / "sim_rep")) + block = _bare_report_agent("sim_rep")._world_evolution_block() + assert block.startswith("【世界演化轨迹(elicited model projection") + assert "已推演 3 个时段" in block and "追踪变量 2 个" in block + assert "⚠️ 其中 1 个时段的仲裁更新失败" in block + assert "· Tariff rate: 10% → 25%(2026-Q3) → 25%(2026-Q4)" in block + assert "决定性行动:" in block and "2026-Q3: Impose or raise a tariff → Actor1" in block + assert "Actor1 holds position" not in block + assert "联盟/对立变动:" in block and "2026-Q3: Actor0 — Actor1 → adversarial" in block + assert block.rstrip().endswith("现实世界的定量声明只能来自研究来源。") + # absent artifacts → empty (legacy / hours runs unchanged) + assert _bare_report_agent("sim_none")._world_evolution_block() == "" + + +def test_collect_viz_artifacts_includes_world_state(tmp_path, monkeypatch): + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + _write_world(str(tmp_path / "sim_viz")) + agent = _bare_report_agent("sim_viz") + agent.actors = None + agent._forecast_spine = None + agent.official_data = None + monkeypatch.setattr(type(agent), "_locate_timeline", lambda self: None, raising=False) + monkeypatch.setattr(type(agent), "_locate_handoff_dir", lambda self: None, raising=False) + arts = agent._collect_viz_artifacts("rep", str(tmp_path / "report")) + assert arts["world_state"]["committed_round"] == 3 + assert "world_state_trajectory" not in arts + + +# ----------------------------------------------------------------------------- chart +def test_world_variables_chart_indexes_numeric_histories(tmp_path): + from app.services.report_visualizer import ReportVisualizer + viz = ReportVisualizer() + if not viz._interactive_ok(): + pytest.skip("plotly unavailable") + charts = str(tmp_path / "charts") + rel = viz.build_world_variables_html(_world_state_payload(), charts) + assert rel is not None + html = open(os.path.join(str(tmp_path), rel), encoding="utf-8").read() + assert "Simulated World Variables" in html and "Tariff rate" in html + assert "2026-Q3" in html and "250" in html # 25/10 → index 250 + assert "Permit" not in html # categorical variables are not charted + assert viz.build_world_variables_html({"world": {"variables": []}}, charts) is None + assert viz.build_world_variables_html("garbage", charts) is None + + +# ----------------------------------------------------------------------------- orchestrator summary +def test_decision_channel_outcome_logs_world_model(tmp_path, monkeypatch): + from app.services import pipeline_orchestrator as po + monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + sim_dir = str(tmp_path / "sim_orch") + _write_world(sim_dir) + with open(os.path.join(sim_dir, "simulation_config.json"), "w", encoding="utf-8") as f: + json.dump({"world_state_seed": {"scenarios": ["A", "B"]}}, f) + saved = {} + monkeypatch.setattr(po.PipelineManager, "save", lambda state: saved.setdefault("state", state)) + + class _State: + pipeline_id = "pipe_x" + options = {} + + orch = po.PipelineOrchestrator.__new__(po.PipelineOrchestrator) + orch._log_decision_channel_outcome(_State(), "sim_orch") + summary = _State.options["decision_channel_summary"] + assert summary["world_model"]["rounds_committed"] == 3 and summary["world_model"]["arbiter_failed_rounds"] == 1 + assert summary["world_headline"].startswith("2027-Q1") + assert summary["trajectory_produced"] is False # no trajectory file written here + + +def test_world_variable_series_seed_at_as_of_and_one_point_per_period(): + from app.services.report_visualizer import world_variable_series + world = { + "as_of_date": "2026-07-11", "round": 4, "committed_round": 4, + "period_labels": {"1": "2026-Q3", "2": "2026-Q4", "3": "2027-Q1", "4": "2027-Q2"}, + "variables": [ + {"id": "tariff_rate", "name": "Tariff rate", "unit": "%", "kind": "numeric", "history": [ + {"round": 0, "value": 10.0, "cause": "seed", "source": "research"}, + {"round": 2, "value": 25.0, "cause": "effect fired", "source": "effect"}, + {"round": 2, "value": 30.0, "cause": "decree", "source": "arbiter"}, # same period: last wins + {"round": 4, "value": 40.0, "cause": "snapback", "source": "arbiter"}, + {"round": 4, "value": "bad", "cause": "ignored"}]}, + {"id": "licences", "name": "Licences issued", "unit": "count", "kind": "numeric", "history": [ + {"round": 3, "value": 5, "cause": "arbiter created the fact", "source": "arbiter"}, + {"round": 4, "value": 6, "cause": "more", "source": "arbiter"}]}, + {"id": "permit", "name": "Permit", "unit": "", "kind": "categorical", + "history": [{"round": 0, "value": "pending"}, {"round": 1, "value": "granted"}]}, + {"id": "flat", "name": "Seed only", "unit": "%", "kind": "numeric", + "history": [{"round": 0, "value": 3.0, "cause": "seed"}]}, + "garbage", + ], + } + rows = world_variable_series(world) + assert [r["id"] for r in rows] == ["tariff_rate", "licences"] # categorical / seed-only / junk skipped + tariff = rows[0] + assert tariff["x"] == ["2026-07-11", "2026-Q3", "2026-Q4", "2027-Q1", "2027-Q2"] # seed at the as-of date + assert tariff["rounds"] == [0, 1, 2, 3, 4] and len(set(tariff["x"])) == len(tariff["x"]) + assert tariff["y"] == [100.0, 100.0, 300.0, 300.0, 400.0] # last value of the period, carried + assert tariff["raw"] == [10.0, 10.0, 30.0, 30.0, 40.0] + assert tariff["hover"] == ["10 % — seed (research, as of 2026-07-11)", "10 % — unchanged", "30 % — decree", + "30 % — unchanged", "40 % — snapback"] + created = rows[1] # an arbiter-created variable starts later + assert created["x"] == ["2027-Q1", "2027-Q2"] and created["y"] == [100.0, 120.0] + assert created["hover"][0] == "5 count — arbiter created the fact" + # no committed round recorded → the history's last round bounds the axis; no as-of → "seed" + bare = world_variable_series({"variables": [{"kind": "numeric", "history": [ + {"round": 0, "value": 2}, {"round": 2, "value": 1}]}]}) + assert bare[0]["x"] == ["seed", "period 1", "period 2"] and bare[0]["y"] == [100.0, 100.0, 50.0] + assert world_variable_series("garbage") == [] and world_variable_series({"variables": []}) == [] + + +def test_world_variables_chart_starts_at_seed_without_duplicates(tmp_path): + from app.services.report_visualizer import ReportVisualizer + viz = ReportVisualizer() + if not viz._interactive_ok(): + pytest.skip("plotly unavailable") + payload = _world_state_payload() + payload["world"]["variables"][0]["history"].append( + {"round": 2, "value": 30.0, "cause": "second row of the period", "source": "arbiter"}) + charts = str(tmp_path / "charts") + rel = viz.build_world_variables_html(payload, charts) + assert rel is not None + html = open(os.path.join(str(tmp_path), rel), encoding="utf-8").read() + assert '"2026-07-11"' in html and '"2026-Q3"' in html and '"2027-Q1"' in html # seed at as-of, one x per period + assert '"categoryarray":["2026-07-11","2026-Q3","2026-Q4","2027-Q1"]' in html + assert "300" in html and "second row of the period" in html and "unchanged" in html + assert html.count('"2026-Q4"') == 2 # one point + the axis order, no duplicate diff --git a/backend/tests/test_world_model_loop.py b/backend/tests/test_world_model_loop.py new file mode 100644 index 00000000..faec2097 --- /dev/null +++ b/backend/tests/test_world_model_loop.py @@ -0,0 +1,543 @@ +"""Reflexive world model in the real calendar round loop (design §2.1, acceptance A1–A4, A7). + +Drives ``run_twitter_simulation`` / ``run_reddit_simulation`` with the FakeEnv harness +from ``test_calendar_round_loop`` and a scripted LLM stand-in that answers both the +per-actor decision prompts and the arbiter prompt. Pins: + + A1 a round-1 decision (tariff raised) is a world fact in the round-2 WORLD STATE brief + every agent receives, and in the round-2 decision prompt (reflexivity); + A2 one decision per actor per round even with two platforms (shared future); + A3 no platform begins round r+1 before world update r is committed (barrier); + A4 arbiter failure → the round still commits with the deterministic update; + A7 SIM_WORLD_MODEL=false / hours mode → no world artifacts, legacy path intact; + resume: already-committed rounds are not re-decided or re-committed; + live follow-ups (sim_d6ebcc77e22d): a provider content-filter rejection of the arbiter is + recorded with class / HTTP status / provider code, and a public statement in the wrong + script for the run language is rejected with a typed status → organic post. +""" + +import asyncio +import json +import os +import re +import sys + +import pytest + +_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SCRIPTS = os.path.join(_BACKEND, "scripts") +for _p in (_BACKEND, _SCRIPTS): + if _p not in sys.path: + sys.path.insert(0, _p) + +import run_parallel_simulation as rps # noqa: E402 +from action_logger import PlatformActionLogger # noqa: E402 +import app.services.decision_channel as dc # noqa: E402 +from tests.conftest import content_filter_error # noqa: E402 +from test_calendar_round_loop import ( # noqa: E402 + _FakeAgent, + _FakeGraph, + _calendar_config, + _fake_elicit, + _hours_config, + _patch_runtime, + _read_events, + _run, + _world_clock_notes, +) + + +# =========================================================================== +# scripted LLM: answers decision prompts per actor and the arbiter prompt +# =========================================================================== +class _WorldLLM: + calls = [] # [{"kind", "round", "actor", "content"}] + arbiter_fail = False + arbiter_error = None # callable → exception raised by the arbiter call (provider rejection) + wrong_language_rounds = () # rounds in which Actor0's statement comes out in Chinese + + def __init__(self, *a, **k): + self.provider = "fake" + self.model = "fake-1" + + @staticmethod + def _round_of(content): + m = re.search(r"round (\d+)/(\d+)", content) + return int(m.group(1)) if m else None + + def chat_json(self, messages, temperature=0.3, max_tokens=4096, **kwargs): + content = messages[-1]["content"] + rnd = self._round_of(content) + if "# DECIDE YOUR MOVE FOR THIS PERIOD" in content: + actor = re.search(r"# DECIDE YOUR MOVE FOR THIS PERIOD — (.+)", content).group(1).strip() + _WorldLLM.calls.append({"kind": "decision", "round": rnd, "actor": actor, "content": content, + "system": messages[0]["content"] if messages[0]["role"] == "system" else ""}) + if actor == "Actor0": + option = "impose_measure" if "[impose_measure]" in content else re.search(r"\[(\w+)\]", content).group(1) + statement = (f"关税上调至25%(第{rnd}轮)。" if rnd in _WorldLLM.wrong_language_rounds + else f"Tariff raised to 25% on imports (round {rnd}).") + return {"option_id": option, "targets": ["Actor1"], "magnitude": 0.9, + "public_statement": statement, + "private_rationale": "protect domestic producers", + "commitment": {"description": "Publish the tariff schedule", "due_periods": 1}, + "alliance_moves": [{"target": "Actor2", "move": "propose"}], + "scenario_commitment": {"scenario": "A", "confidence": 0.9}} + return {"option_id": "hold_position", "scenario_commitment": "abstain"} + if "consequence arbiter" in content: + _WorldLLM.calls.append({"kind": "arbiter", "round": None, "actor": None, "content": content}) + if _WorldLLM.arbiter_error is not None: + raise _WorldLLM.arbiter_error() + if _WorldLLM.arbiter_fail: + raise RuntimeError("arbiter provider down") + moved = "Actor0: Impose or raise a tariff" in content + return { + "narrative": "Tariff shock ripples through importers." if moved else "Quiet period.", + "variable_changes": ([{"variable_id": "tariff_rate", "new_value": 25, "rationale": "decree"}] + if moved and "tariff_rate: Tariff rate = 10%" in content else []), + "events": [{"description": "Importers protest the tariff", "kind": "emergent", + "actors": ["Actor1"], "targets": ["Actor0"]}] if moved else [], + "action_resolutions": [{"actor": "Actor0", "outcome": "success"}] if moved else [], + "scenario_pressure": {"A": "up"}, + } + raise AssertionError(f"unexpected prompt: {content[:80]}") + + +def _world_config(**kw): + cfg = _calendar_config(**kw) + cfg["world_model"] = { + "schema_version": "world-model-seed/v1", + "question": "Will scenario A happen by Q1 2027?", + "as_of_date": "2026-07-11", + "horizon_date": "2027-03-31", + "state_variables": [{"name": "Tariff rate", "value": 10, "unit": "%", "provenance": "research"}], + "actors": [ + {"agent_id": 0, "name": "Actor0", "agency_class": "sovereign_government", "capacity_per_round": 3}, + {"agent_id": 1, "name": "Actor1", "agency_class": "firm"}, + {"agent_id": 2, "name": "Actor2", "agency_class": "industry_coalition"}, + {"agent_id": 3, "name": "Actor3", "agency_class": "ngo_advocacy"}, + ], + "relationships": [{"source": "Actor0", "target": "Actor1", "type": "REGULATES", "valence": "directional"}], + "settings": {"max_change_ratio": 5.0, "arbiter_enabled": True}, + } + return cfg + + +def _patch_world(monkeypatch, sim_dir, envs): + _patch_runtime(monkeypatch, sim_dir, envs) + _WorldLLM.calls = [] + _WorldLLM.arbiter_fail = False + _WorldLLM.arbiter_error = None + _WorldLLM.wrong_language_rounds = () + monkeypatch.setattr("app.utils.llm_client.LLMClient", _WorldLLM) + monkeypatch.setattr(dc, "elicit_round", lambda *a, **k: pytest.fail("legacy elicit must not run in world mode")) + rps._WORLD_COORDINATORS.clear() + rps._WORLD_TRAJ_WRITTEN.clear() + + +def _read_jsonl(path): + with open(path, encoding="utf-8") as f: + return [json.loads(line) for line in f if line.strip()] + + +# =========================================================================== +# A1 + artifacts: single platform, three rounds +# =========================================================================== +def test_world_model_round_loop_is_reflexive(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + _run(_world_config(), sim_dir) + env = envs[0] + + # every active agent decided → no organic LLMAction step was needed; statements were ManualActions + assert env.llm_steps == [] + decisions = [c for c in _WorldLLM.calls if c["kind"] == "decision"] + arbiters = [c for c in _WorldLLM.calls if c["kind"] == "arbiter"] + assert [c["round"] for c in decisions if c["actor"] == "Actor0"] == [1, 2, 3] + assert len(arbiters) == 3 + assert all(c["system"].startswith("You are Actor") for c in decisions) # the sealed role brief is the system turn + + # ---- reflexivity: round-2 WORLD CLOCK carries the committed world of round 1 ---- + notes = _world_clock_notes(env) + r2 = [n for n in notes if "round 2/3" in n] + assert r2 + assert "# WORLD STATE — as of 2026-09-30 (after 2026-Q3)" in r2[0] + assert "- Tariff rate: 25% (was 10% last period)" in r2[0] + assert "## Moves last period" in r2[0] and 'Actor0: Impose or raise a tariff' in r2[0] + assert "Tariff raised to 25% on imports (round 1)." in r2[0] + assert "Importers protest the tariff" in r2[0] + assert "## Alliances and rivalries" in r2[0] and "Actor0 — Actor2" in r2[0] + assert "## Pending and announced" in r2[0] and "Publish the tariff schedule (due 2026-Q4)" in r2[0] + assert "## WHAT CHANGED LAST PERIOD\nTariff shock ripples through importers." in r2[0] + # herding guard: the scenario distribution never appears in agent-facing text + assert not re.search(r"\bA\b\s*[:=]\s*\d", r2[0]) and "60%" not in r2[0] and "0.6" not in r2[0] + # round 1 shows the seeded world and no moves yet + r1 = [n for n in notes if "round 1/3" in n][0] + assert "- Tariff rate: 10%" in r1 + assert "## Moves last period" not in r1 and "(first period)" in r1 + + # ---- the round-2 decision prompt sees the changed world and the actor's own outcome ---- + d2 = [c for c in decisions if c["actor"] == "Actor0" and c["round"] == 2][0]["content"] + assert "- Tariff rate: 25% (was 10% last period)" in d2 + assert "# YOUR SITUATION — Actor0" in d2 and "outcome so far: success" in d2 + assert "Publish the tariff schedule" in d2 and "(DUE NOW)" in d2 + assert "Relationships that moved last period: Actor2 (neutral)" in d2 # a proposal is not yet an alliance + assert "[hold_position]" in d2 and "Candidate scenarios: A; B" in d2 + + # ---- artifacts ---- + state = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert state["committed_round"] == 3 and state["summary"]["rounds_committed"] == 3 + assert state["world"]["variables"][0]["value"] == 25.0 + assert state["world"]["variables"][0]["history"][-1]["source"] == "arbiter" + history = _read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl")) + assert [h["round"] for h in history] == [1, 2, 3] + assert history[0]["arbiter_status"] == "applied" and history[0]["diff"]["variables_changed"][0]["to"] == 25.0 + assert history[0]["scenario_round_status"] == "committed" and history[0]["shares"]["A"] > 0.6 + assert history[0]["period_label"] == "2026-Q3" + actions_rows = _read_jsonl(os.path.join(sim_dir, "world_actions.jsonl")) + round1_actors = [r["actor_name"] for r in actions_rows if r["round"] == 1] + assert "Actor0" in round1_actors and 2 <= len(round1_actors) <= 3 # principal + sampled (agents_per_hour_max=2) + assert len(round1_actors) == len(set(round1_actors)) + a0 = [r for r in actions_rows if r["actor_name"] == "Actor0" and r["round"] == 1][0] + assert a0["option"]["id"] == "impose_measure" and a0["targets"] == ["Actor1"] and a0["scenario"] == "A" + + events = _read_events(sim_dir) + posts = [e for e in events if e.get("action_type") == "CREATE_POST" and e.get("action_args", {}).get("is_world_decision")] + assert [p["round"] for p in posts] == [1, 2, 3] and posts[0]["agent_name"] == "Actor0" + assert posts[0]["action_args"]["option_id"] == "impose_measure" + updates = [e for e in events if e.get("event_type") == "world_update"] + assert [u["round"] for u in updates] == [1, 2, 3] + assert updates[0]["period_label"] == "2026-Q3" and updates[0]["arbiter_status"] == "applied" + assert "1 facts changed" in updates[0]["headline"] + # round_end counts the published decision post + ends = {e["round"]: e for e in events if e.get("event_type") == "round_end"} + assert ends[1]["actions_count"] >= 1 + + # scenario-share trajectory keeps its contract and moves toward A + traj = json.load(open(os.path.join(sim_dir, "world_state_trajectory.json"), encoding="utf-8")) + assert traj["schema_version"] == 3 and traj["mode"] == "calendar" and traj["calendar_unit"] == "quarter" + assert traj["outcome"]["leader"] == "A" and traj["outcome"]["shares"]["A"] > 0.6 + assert traj["world_model"]["rounds_committed"] == 3 and traj["world_model"]["arbiter_failed_rounds"] == 0 + assert [r["round_status"] for r in traj["trajectory"][1:]] == ["committed"] * 3 + dec_rows = _read_jsonl(os.path.join(sim_dir, "decisions.jsonl")) + assert dec_rows and all(r["scenario"] == "A" and r["period_end"] for r in dec_rows) + digest_rows = _read_jsonl(os.path.join(sim_dir, "world_digest.jsonl")) + assert [r["round"] for r in digest_rows] == [1, 2, 3] and "Tariff shock" in digest_rows[0]["digest"] + # the agent's private memory carries its own move for the response step + agent0 = env.agent_graph.get_agent(0) + moves = [c for c, _r in agent0.memory_notes if c.startswith("# YOUR MOVE THIS PERIOD")] + assert moves and "You chose: Impose or raise a tariff" in moves[0] and "Directed at: Actor1" in moves[0] + + +# =========================================================================== +# A2 + A3: two platforms share one decision per actor and run in lockstep +# =========================================================================== +def _patch_reddit(monkeypatch, sim_dir): + with open(os.path.join(sim_dir, "reddit_profiles.json"), "w", encoding="utf-8") as f: + json.dump([{"username": f"actor{i}", "name": f"Actor{i}", "persona": f"You are Actor{i}."} for i in range(4)], f) + + async def _fake_reddit_graph(profile_path=None, model=None, available_actions=None): + return _FakeGraph([_FakeAgent(i, f"Actor{i}") for i in range(4)]) + + monkeypatch.setattr(rps, "generate_reddit_agent_graph", _fake_reddit_graph) + + +def test_two_platforms_share_decisions_and_respect_barrier(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + _patch_reddit(monkeypatch, sim_dir) + seq = [] + orig_begin = rps._ReflexiveWorldCoordinator.begin_round + orig_commit = rps._ReflexiveWorldCoordinator._commit_round + + async def _begin(self, platform, round_num, period, action_logger=None): + out = await orig_begin(self, platform, round_num, period, action_logger) + seq.append(("begin", platform, round_num)) + return out + + async def _commit(self, round_num, buf): + await orig_commit(self, round_num, buf) + seq.append(("commit", None, round_num)) + + monkeypatch.setattr(rps._ReflexiveWorldCoordinator, "begin_round", _begin) + monkeypatch.setattr(rps._ReflexiveWorldCoordinator, "_commit_round", _commit) + + cfg = _world_config() + + async def _both(): + return await asyncio.gather( + rps.run_twitter_simulation(cfg, sim_dir, action_logger=PlatformActionLogger("twitter", sim_dir), + main_logger=None, semaphore_platforms=2), + rps.run_reddit_simulation(cfg, sim_dir, action_logger=PlatformActionLogger("reddit", sim_dir), + main_logger=None, semaphore_platforms=2), + ) + + asyncio.run(_both()) + + decisions = [c for c in _WorldLLM.calls if c["kind"] == "decision"] + # A2: each actor decided exactly once per round although two platforms ran + assert sorted(c["round"] for c in decisions if c["actor"] == "Actor0") == [1, 2, 3] + pairs = [(c["actor"], c["round"]) for c in decisions] + assert len(pairs) == len(set(pairs)) and 6 <= len(pairs) <= 9 # one decision per (actor, round) + assert len([c for c in _WorldLLM.calls if c["kind"] == "arbiter"]) == 3 + # A3: for every round r ≥ 1, both platforms began r only after commit r-1 + commits = {r: i for i, (kind, _p, r) in enumerate(seq) if kind == "commit"} + assert sorted(commits) == [0, 1, 2] + for i, (kind, platform, r) in enumerate(seq): + if kind == "begin" and r > 0: + assert commits[r - 1] < i, f"{platform} began round {r + 1} before world update {r} committed" + # both platforms published the same statement and logged the same world update rows + for plat in ("twitter", "reddit"): + rows = _read_jsonl(os.path.join(sim_dir, plat, "actions.jsonl")) + posts = [e for e in rows if e.get("action_type") == "CREATE_POST" and e.get("action_args", {}).get("is_world_decision")] + assert [p["round"] for p in posts] == [1, 2, 3], plat + assert [u["round"] for u in rows if u.get("event_type") == "world_update"] == [1, 2, 3], plat + history = _read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl")) + assert [h["round"] for h in history] == [1, 2, 3] + state = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert state["committed_round"] == 3 and state["world"]["variables"][0]["value"] == 25.0 + assert os.path.exists(os.path.join(sim_dir, "world_state_trajectory.json")) + + +# =========================================================================== +# A4: arbiter failure → deterministic commit, loop survives +# =========================================================================== +def test_arbiter_failure_round_commits_deterministically(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + _WorldLLM.arbiter_fail = True + _run(_world_config(), sim_dir) + env = envs[0] + history = _read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl")) + assert [h["arbiter_status"] for h in history] == ["failed"] * 3 + assert history[0]["arbiter_problems"] == ["llm error: RuntimeError: arbiter provider down"] + state = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert state["committed_round"] == 3 + assert state["world"]["variables"][0]["value"] == 10.0 # no fabricated numeric effect + assert state["summary"]["arbiter_failed_rounds"] == 3 + # the deterministic world still moved: Actor0's move, its commitment and the scheduled event are visible + notes = _world_clock_notes(env) + r2 = [n for n in notes if "round 2/3" in n][0] + assert "## Moves last period" in r2 and "Tariff raised to 25% on imports (round 1)." in r2 + assert "Publish the tariff schedule" in r2 + r3 = [n for n in notes if "round 3/3" in n][0] + assert "[2026-11-05] 事件A发生" in r3 # scheduled event of round 2 → round-3 brief + traj = json.load(open(os.path.join(sim_dir, "world_state_trajectory.json"), encoding="utf-8")) + assert traj["world_model"]["arbiter_failed_rounds"] == 3 and traj["outcome"]["leader"] == "A" + + +def test_arbiter_content_filter_rejection_is_recorded_precisely(tmp_path, monkeypatch): + """Live run sim_d6ebcc77e22d: two arbiter calls were rejected by the provider content filter + (HTTP 400, code 1301) and the history only said ``llm error: BadRequestError``. The record now + names class, status, code and classification; the round still commits deterministically.""" + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + _WorldLLM.arbiter_error = content_filter_error + _run(_world_config(), sim_dir) + history = _read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl")) + assert [h["arbiter_status"] for h in history] == ["failed"] * 3 + for row in history: + [problem] = row["arbiter_problems"] + assert problem.startswith("llm error: BadRequestError (HTTP 400, provider code 1301, content filter): " + "Error code: 400 - {'contentFilter'") + assert "'code': '1301'" in problem and len(problem) <= 320 + state = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert state["committed_round"] == 3 and state["world"]["variables"][0]["value"] == 10.0 + assert state["summary"]["arbiter_failed_rounds"] == 3 + updates = [e for e in _read_events(sim_dir) if e.get("event_type") == "world_update"] + assert [u["arbiter_status"] for u in updates] == ["failed"] * 3 + # the deterministic world still moved and the loop kept deciding + assert [c["round"] for c in _WorldLLM.calls if c["kind"] == "decision" and c["actor"] == "Actor0"] == [1, 2, 3] + + +def test_wrong_language_statement_is_rejected_and_actor_posts_organically(tmp_path, monkeypatch): + """A Chinese public_statement in an English run (live: MOFCOM, round 4) is rejected with a typed + status: no decision post, the actor falls back to its organic post, and the rejected text never + enters the shared world.""" + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + _WorldLLM.wrong_language_rounds = (2,) + cfg = _world_config() + cfg["output_language"] = "English" + _run(cfg, sim_dir) + env = envs[0] + d2 = [c for c in _WorldLLM.calls if c["kind"] == "decision" and c["actor"] == "Actor0" and c["round"] == 2][0] + assert "Write public_statement in English." in d2["content"] + rows = _read_jsonl(os.path.join(sim_dir, "world_actions.jsonl")) + a0 = {r["round"]: r for r in rows if r["actor_name"] == "Actor0"} + assert a0[1]["status"] == "ok" and a0[3]["status"] == "ok" + assert a0[2]["status"] == "language_mismatch" and a0[2]["option"]["id"] == "hold_position" + assert a0[2]["raw_option_id"] not in ("", "hold_position") and a0[2]["public_statement"] == "" # the move it tried + assert a0[2]["problems"] == ['public_statement is cjk-script but the run language is English: "关税上调至25%(第2轮)。"'] + # publication: no decision post in round 2; Actor0 fell back to the organic post instead + posts = [e for e in _read_events(sim_dir) + if e.get("action_type") == "CREATE_POST" and e.get("action_args", {}).get("is_world_decision")] + assert [p["round"] for p in posts] == [1, 3] + assert env.llm_steps == [[0]] + # the rejected text never reaches the shared world: round 2 records no move for Actor0 (typed), + # and the round-3 WORLD STATE brief carries the typed non-move instead of the Chinese statement + history = _read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl")) + assert history[1]["decisions"]["language_mismatch"] == 1 + assert history[1]["decisions"]["ok"] == history[1]["decisions"]["total"] - 1 + r3 = [n for n in _world_clock_notes(env) if "round 3/3" in n][0] + assert "- Actor0: Actor0 makes no recorded move (language_mismatch)" in r3 + digest_rows = _read_jsonl(os.path.join(sim_dir, "world_digest.jsonl")) + assert "关税上调至25%" not in r3 and "关税" not in digest_rows[1]["digest"] + state = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert state["committed_round"] == 3 + + +# =========================================================================== +# A7: switch off / hours mode → legacy behaviour, no world artifacts +# =========================================================================== +def test_world_model_off_falls_back_to_legacy_inband(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs, calls = [], [] + _patch_runtime(monkeypatch, sim_dir, envs) + monkeypatch.setenv("SIM_WORLD_MODEL", "false") + monkeypatch.setattr(dc, "elicit_round", _fake_elicit(calls)) + rps._WORLD_COORDINATORS.clear() + _run(_world_config(), sim_dir) + assert not os.path.exists(os.path.join(sim_dir, "world_state.json")) + assert not os.path.exists(os.path.join(sim_dir, "world_actions.jsonl")) + assert len(calls) == 3 # legacy in-band elicitation ran + assert len(envs[0].llm_steps) == 3 # agents posted organically + assert os.path.exists(os.path.join(sim_dir, "world_state_trajectory.json")) + assert not any(e.get("event_type") == "world_update" for e in _read_events(sim_dir)) + + +def test_hours_mode_ignores_world_model_block(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + cfg = _hours_config() + cfg["world_model"] = _world_config()["world_model"] + _run(cfg, sim_dir, max_rounds=1) + assert len(envs[0].llm_steps) == 1 + assert _WorldLLM.calls == [] + assert not os.path.exists(os.path.join(sim_dir, "world_state.json")) + + +# =========================================================================== +# resume: committed rounds are not re-decided or re-committed +# =========================================================================== +def test_resume_skips_committed_world_rounds(tmp_path, monkeypatch): + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + cfg = _world_config() + _run(cfg, sim_dir) + before = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + n_calls = len(_WorldLLM.calls) + # fake a crash after round 1 on this platform; the world itself already committed all three rounds + ckpt_path = os.path.join(sim_dir, "twitter", "checkpoint.json") + db_path = os.path.join(sim_dir, "twitter_simulation.db") + with open(ckpt_path, "w", encoding="utf-8") as f: + json.dump({"platform": "twitter", "completed_round": 1, "last_rowid": rps._max_trace_rowid(db_path), + "total_rounds": 3, "total_actions": 0}, f) + rps._WORLD_COORDINATORS.clear() + _run(cfg, sim_dir, resume=True) + env2 = envs[1] + assert len(_WorldLLM.calls) == n_calls # no re-decision, no re-arbitration + assert len(env2.llm_steps) == 2 # replayed rounds post organically + after = json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8")) + assert after["committed_round"] == 3 and after["world"]["variables"] == before["world"]["variables"] + assert len(_read_jsonl(os.path.join(sim_dir, "world_state_history.jsonl"))) == 3 + # the replayed rounds still observe the committed world brief + notes = _world_clock_notes(env2) + assert any("# WORLD STATE" in n for n in notes) + + +def test_resume_restores_partial_trajectory_and_continues_remaining_rounds(tmp_path, monkeypatch): + """A run interrupted after round 1 resumes with the committed world AND the scenario-share + trajectory restored, then commits the remaining rounds without re-deciding round 1. + (Calendar mode ignores max_rounds by design, so the interruption is the runner's shutdown + signal, raised right after the first world update commits.)""" + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + cfg = _world_config() + orig_commit = rps._ReflexiveWorldCoordinator._commit_round + stop = asyncio.Event() + + async def _commit_then_stop(self, round_num, buf): + await orig_commit(self, round_num, buf) + if round_num == 0: + stop.set() # the loop checks this before round 2 + + monkeypatch.setattr(rps._ReflexiveWorldCoordinator, "_commit_round", _commit_then_stop) + monkeypatch.setattr(rps, "_shutdown_event", stop) + _run(cfg, sim_dir) + traj_before = json.load(open(os.path.join(sim_dir, "world_state_trajectory.json"), encoding="utf-8")) + assert [r["round"] for r in traj_before["trajectory"]] == [0, 1] + assert json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8"))["committed_round"] == 1 + round1_decisions_before = len([c for c in _WorldLLM.calls if c["kind"] == "decision" and c["round"] == 1]) + # second process: no shutdown signal, fresh coordinator, checkpoint says round 1 is complete + monkeypatch.setattr(rps._ReflexiveWorldCoordinator, "_commit_round", orig_commit) + monkeypatch.setattr(rps, "_shutdown_event", None) + rps._WORLD_COORDINATORS.clear() + _run(cfg, sim_dir, resume=True) + traj_after = json.load(open(os.path.join(sim_dir, "world_state_trajectory.json"), encoding="utf-8")) + assert [r["round"] for r in traj_after["trajectory"]] == [0, 1, 2, 3] + assert traj_after["trajectory"][1]["shares"] == traj_before["trajectory"][1]["shares"] + assert len([c for c in _WorldLLM.calls if c["kind"] == "decision" and c["round"] == 1]) == round1_decisions_before + assert json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8"))["committed_round"] == 3 + + +# =========================================================================== +# observability: the coordinator's per-round lines reach simulation.log intact +# =========================================================================== +def test_world_round_lines_reach_simulation_log(tmp_path, monkeypatch): + """The backend streams the runner's stdout/stderr into simulation.log through its own handle + while the runner's SimulationLogManager writes the same file: both append, so the per-round + world lines are on disk, whole, in the main logger's format (the live run found them torn).""" + import contextlib + from action_logger import SimulationLogManager + from app.services.simulation_runner import SimulationRunner + + sim_dir = str(tmp_path) + envs = [] + _patch_world(monkeypatch, sim_dir, envs) + cfg = _world_config() + sink = SimulationRunner._open_main_log(sim_dir) # the backend's stdout/stderr capture handle + assert sink.mode == "a" + manager = None + try: + with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): + manager = SimulationLogManager(sim_dir) # as in main(): after the capture exists + print("library banner printed before the first round") + asyncio.run(rps.run_twitter_simulation(cfg, sim_dir, action_logger=PlatformActionLogger("twitter", sim_dir), + main_logger=manager)) + finally: + sink.close() + if manager is not None: + for handler in list(manager._main_logger.handlers): + handler.close() + manager._main_logger.handlers.clear() + + with open(os.path.join(sim_dir, "simulation.log"), "rb") as f: + text = f.read().decode("utf-8") # strict: no torn multi-byte characters anywhere + lines = text.splitlines() + records = [line.split(" - INFO - ", 1)[1] for line in lines if " - INFO - " in line] + world = [r for r in records if r.startswith("[Twitter] round") or r.startswith("[Twitter] world update committed")] + assert [re.match(r"\[Twitter\] round (\d) decisions: \d+ actors → \{", r).group(1) + for r in world if " decisions: " in r] == ["1", "2", "3"] + assert [r.split(": ", 1)[1].split(":", 1)[0] for r in world if r.startswith("[Twitter] world update committed")] == [ + "2026-Q3", "2026-Q4", "2027-Q1"] + kinds = ["decide" if " decisions: " in r else "commit" if "committed" in r else "publish" for r in world] + assert kinds == ["decide", "publish", "commit"] * 3 # each round: decide → publish → commit + assert any(r.startswith("[Twitter] reflexive world complete: leader=A") for r in records) + # the captured console copies land whole too, and the file keeps what was printed before the manager existed + assert "library banner printed before the first round" in lines + assert "[Twitter] round 1 decisions: " in "\n".join(lines) and sum( + 1 for line in lines if line.startswith("[Twitter] world update committed: 2026-Q3")) == 1 + assert not any(re.match(r"^\[\d\d:\d\d:\d\d\] ", line) for line in lines) # no console-handler echo into the same file + assert json.load(open(os.path.join(sim_dir, "world_state.json"), encoding="utf-8"))["committed_round"] == 3 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-x", "-q"])) diff --git a/backend/tests/test_world_model_seed.py b/backend/tests/test_world_model_seed.py new file mode 100644 index 00000000..7178eb8e --- /dev/null +++ b/backend/tests/test_world_model_seed.py @@ -0,0 +1,460 @@ +"""PREPARE-time ``world_model`` seed (design §2.7, acceptance A8) and ontology agency metadata. + +Offline. Pins: variables from quantitative facts + official series (as-of cutoff, cap), +agency class resolution order, canonical dossiers read only validated pack rows (no +analyst inference), relationships restricted to the cast with allies/rivals, audience +excluded, config emission + sealing, agent-config additive fields, ontology normalisation. +""" + +import json +from dataclasses import asdict + +import pytest + +from app.services import world_model_seed as wms +from app.services.agent_decision import AGENCY_CLASSES +from app.services.ontology_generator import OntologyGenerator +from app.services.simulation_config_generator import AgentActivityConfig, SimulationParameters +from app.services.world_model import WorldModel + + +def _legacy_actors(): + return { + "as_of_date": "2026-07-01", + "central_question": "Will the grid permit unlock deployment by 2027?", + "actors": [ + {"name": "Alpha Systems", "type": "Organization", "role": "grid developer", "aliases": ["Alpha"], + "stance": "supportive", "influence": "high", "goals": ["Shape the permit timetable"], + "future_plans": ["Build the Aurora line by 2027"], "likely_actions": ["File a permit response"], + "red_lines": ["No unfinanced mandate"], "outcome_power": 1.5}, + {"name": "Beta Union", "type": "Organization", "role": "workers union", "stance": "opposing", + "incentives": [{"gains_if": "members keep jobs", "loses_if": "automation accelerates"}]}, + {"name": "Grid Regulator", "type": "GovernmentAgency", "role": "state regulator", "stance": "neutral"}, + {"name": "Daily Wire", "type": "MediaOutlet", "role": "newspaper"}, + ], + "relationships": [ + {"source": "Alpha Systems", "target": "Beta Union", "type": "OPPOSES", "strength": "high", "basis": "timetable"}, + {"source": "Grid Regulator", "target": "Alpha Systems", "type": "REGULATES"}, + {"source": "Alpha Systems", "target": "Unknown Co", "type": "PARTNERS_WITH"}, + ], + "quantitative_facts": [ + {"metric": "Alpha planned investment", "value": "US$2 billion", "as_of_date": "2026-06-30", + "source": "10-K", "definition": "capex guidance"}, + {"metric": "Permit status", "value": "pending"}, + {"metric": "", "value": "9"}, + ], + "forecast_inputs": { + "scenarios": [{"name": "Permit granted", "probability_band": "55-65%"}, {"name": "Permit delayed"}], + "indicators": [{"indicator": "Permit docket activity", "direction": "up = granted"}], + }, + } + + +def _official(): + return {"series": [ + {"title": "Regional capacity", "units": "MW", "provider_name": "EIA", "observations": [ + {"date": "2026-03-31", "value": 110}, {"date": "2026-06-30", "value": 120}, {"date": "2026-09-30", "value": 999}]}, + {"title": "No numbers", "units": "x", "observations": [{"date": "2026-01-01", "value": None}]}, + {"series": "ignored-no-units", "observations": [{"date": "2026-01-01", "value": 1}]}, + ]} + + +def _cfgs(include_audience=True): + rows = [ + {"agent_id": 0, "entity_name": "Alpha Systems", "entity_type": "Organization", "stance": "supportive", + "gains_if": "timetable holds", "loses_if": ""}, + {"agent_id": 1, "entity_name": "Beta Union", "entity_type": "Organization", "stance": "opposing"}, + {"agent_id": 2, "entity_name": "Grid Regulator", "entity_type": "GovernmentAgency", "stance": "neutral"}, + {"agent_id": 3, "entity_name": "Daily Wire", "entity_type": "MediaOutlet", "stance": "neutral"}, + ] + if include_audience: + rows.append({"agent_id": 4, "entity_name": "Audience 1", "entity_type": "Audience", "stance": "neutral"}) + return rows + + +class _Timeline: + as_of_date = "2026-07-01" + horizon_date = "2027-03-31" + + +def test_state_variables_from_research_and_official_with_cutoff(): + rows = wms.state_variables_from_research(_legacy_actors(), _official(), max_variables=24) + by_id = {r["id"]: r for r in rows} + inv = by_id["alpha_planned_investment"] + assert inv["value"] == 2e9 and inv["unit"] == "US$" and inv["kind"] == "numeric" + assert inv["as_of"] == "2026-06-30" and inv["provenance"] == "research" + assert "capex guidance; 10-K" == inv["rationale"] + assert by_id["permit_status"]["kind"] == "text" and by_id["permit_status"]["value"] == "pending" + cap = by_id["regional_capacity"] + assert cap["value"] == 120.0 and cap["as_of"] == "2026-06-30" # 2026-09-30 is after the as-of cutoff + assert cap["provenance"] == "official" and cap["rationale"] == "EIA" and cap["unit"] == "MW" + assert "no_numbers" not in by_id and len(rows) == 3 + assert wms.state_variables_from_research(_legacy_actors(), _official(), max_variables=1)[0]["id"] == "alpha_planned_investment" + assert wms.state_variables_from_research(None, None) == [] + + +def test_state_variables_ignore_projected_quantitative_facts(): + actors = { + "as_of_date": "2026-07-01", + "quantitative_facts": [ + {"metric": "Current capacity", "value": "100", "value_type": "actual", "as_of_date": "2026-06-30"}, + {"metric": "Analyst estimate", "value": "120", "value_type": "estimate", "period_end": "2026-07-01"}, + {"metric": "Future target", "value": "200", "value_type": "target", "period_end": "2026-12-31"}, + {"metric": "Forecast value", "value": "210", "value_type": "forecast", "period_end": "2027-03-31"}, + ], + } + rows = wms.state_variables_from_research(actors, None, max_variables=24) + by_id = {r["id"]: r for r in rows} + assert set(by_id) == {"current_capacity", "analyst_estimate"} + + +def test_state_variables_official_respects_snapshot_eligibility(): + actors = {"as_of_date": "2026-07-01", "quantitative_facts": []} + official = { + "series": [ + { + "series": "fred:GDP", + "provider": "fred", + "title": "GDP", + "units": "USD", + "frequency": "Q", + "retrieved_at": "2026-07-01T00:00:00Z", + "observations": [ + {"date": "2026-04-01", "value": 100.0, "flag": "observed"}, + {"date": "2026-10-01", "value": 999.0, "flag": "observed"}, + ], + "provenance": {}, + }, + { + "series": "fred:CPIAUCSL", + "provider": "fred", + "title": "CPI", + "units": "index", + "frequency": "M", + "retrieved_at": "2026-08-01T00:00:00Z", + "observations": [{"date": "2026-06-01", "value": 300.0, "flag": "observed"}], + "provenance": {}, + }, + ], + } + rows = wms.state_variables_from_research(actors, official, max_variables=24) + by_id = {r["id"]: r for r in rows} + assert by_id["gdp"]["value"] == 100.0 + assert "cpi" not in by_id + + +def test_resolve_agency_class_order(): + assert wms.resolve_agency_class({"agency_class": "labor_union"}, "Organization") == "labor_union" + assert wms.resolve_agency_class({"name": "X"}, "Organization", ontology_class="central_bank") == "central_bank" + assert wms.resolve_agency_class({"name": "Grid Regulator", "role": "state regulator"}, "GovernmentAgency") == "regulator_agency" + assert wms.resolve_agency_class({"name": "Daily Wire"}, "MediaOutlet") == "media_outlet" + assert wms.resolve_agency_class(None, "Person") == "individual_leader" + assert wms.resolve_agency_class({"agency_class": "bogus"}, "Organization") == "firm" + + +def test_build_seed_from_legacy_dossier(): + block = wms.build_world_model_seed(_legacy_actors(), _cfgs(), question="Will the permit unlock deployment?", + official_data=_official(), temporal_timeline=_Timeline()) + assert block["schema_version"] == wms.WORLD_MODEL_SEED_VERSION + assert block["as_of_date"] == "2026-07-01" and block["horizon_date"] == "2027-03-31" + assert block["scenarios"] == ["Permit granted", "Permit delayed"] + assert block["scenario_indicators"] == [{"indicator": "Permit docket activity", "direction": "up = granted"}] + actors = {a["agent_id"]: a for a in block["actors"]} + assert set(actors) == {0, 1, 2, 3} # audience excluded + alpha = actors[0] + assert alpha["agency_class"] == "firm" and alpha["capacity_per_round"] == 2 + assert alpha["outcome_power"] == 1.5 and actors[1]["outcome_power"] is None + assert alpha["documented_plans"] == ["Build the Aurora line by 2027", "File a permit response"] + assert alpha["red_lines"] == ["No unfinanced mandate"] and alpha["goals"] == ["Shape the permit timetable"] + assert alpha["incentives"] == {"gains_if": "timetable holds"} and alpha["aliases"] == ["Alpha"] + assert alpha["rivals"] == ["Beta Union"] and actors[1]["rivals"] == ["Alpha Systems"] + assert actors[2]["agency_class"] == "regulator_agency" and actors[2]["capacity_per_round"] == 2 + assert actors[3]["agency_class"] == "media_outlet" and actors[3]["capacity_per_round"] == 1 + rels = block["relationships"] + assert [(r["source"], r["target"], r["valence"]) for r in rels] == [ + ("Alpha Systems", "Beta Union", "adversarial"), ("Grid Regulator", "Alpha Systems", "directional")] + assert rels[0]["polarity"] == -0.4 and rels[0]["source_agent_id"] == 0 + assert block["settings"] == {"arbiter_enabled": True, "max_change_ratio": 0.5, "barrier_timeout_s": 1800.0} + # the block seeds a working world + world = WorldModel.from_seed(block, _cfgs()) + assert world.variables["regional_capacity"].value == 120.0 + assert world.relationships[(0, 1)].valence == "adversarial" + assert world.actors[0].documented_plans[0].startswith("Build the Aurora") + assert world.actors[4].agency_class == "other_actor" # audience agent still has a state + + +def test_canonical_dossier_uses_only_validated_pack_rows(): + from test_actor_context_runtime import REPORT, _dossier + from app.services import actor_context as ac + dossier = _dossier() + packs = {} + for row in dossier["actors"]: + pack = ac.build_actor_context_pack(dossier, row, REPORT) + packs[row["actor_id"]] = pack + cfgs = [{"agent_id": 0, "entity_name": "Alpha Systems", "entity_type": "Actor"}, + {"agent_id": 1, "entity_name": "Beta Union", "entity_type": "Actor"}] + block = wms.build_world_model_seed(dossier, cfgs, actor_context_packs=packs, temporal_timeline=None) + alpha = block["actors"][0] + assert alpha["documented_plans"] == [ + "Alpha Systems plans a 2027 multi-site Project Aurora expansion.", + "Alpha Systems is likely to file a permit response.", + "Alpha Systems is executing Project Aurora now.", + "Alpha Systems plans a $2 billion Project Aurora investment.", + ] + assert alpha["red_lines"] == ["Alpha Systems will not accept an unfinanced immediate mandate."] + # motivations are analyst inference in the fixture → skipped; values remain + assert alpha["goals"] == ["Alpha Systems values operational reliability."] + assert alpha["agency_class"] == "firm" + # the flat legacy fields of a canonical actor are not read + dossier2 = _dossier() + dossier2["actors"][0]["future_plans"] = ["FLAT LEAK"] + block2 = wms.build_world_model_seed(dossier2, cfgs, actor_context_packs=packs) + assert "FLAT LEAK" not in json.dumps(block2) + # without a pack a canonical actor gets no plans at all (never the flat fields) + block3 = wms.build_world_model_seed(dossier2, cfgs, actor_context_packs=None) + assert block3["actors"][0]["documented_plans"] == [] + + +def test_seed_degrades_on_garbage(): + block = wms.build_world_model_seed("not a dict", [{"agent_id": "x"}, None, {"agent_id": 1, "entity_name": "Z"}]) + assert block["actors"][0]["name"] == "Z" and block["state_variables"] == [] and block["relationships"] == [] + # a garbage seed is honest about what it could not ground + assert wms.world_model_seed_summary(block) == "world model: 0 variables, 1 actors, 0 relationships, 4 modelling gaps" + assert sorted(g["kind"] for g in block["gaps"]) == ["actor_unclassified", "actor_without_goals", "actor_without_plans", "no_state_variables"] + + +def test_agent_activity_config_additive_fields_and_parameters_emit_block(): + cfg = AgentActivityConfig(agent_id=0, entity_uuid="u", entity_name="X", entity_type="Org", + agency_class="firm", archetype="actor", outcome_power=2.0) + d = asdict(cfg) + assert d["agency_class"] == "firm" and d["archetype"] == "actor" and d["outcome_power"] == 2.0 + d0 = asdict(AgentActivityConfig(agent_id=1, entity_uuid="u", entity_name="Y", entity_type="Org")) + assert d0["agency_class"] == "" and d0["archetype"] == "" and d0["outcome_power"] is None + params = SimulationParameters(simulation_id="s", project_id="p", graph_id="g", simulation_requirement="q") + assert "world_model" not in params.to_dict() # omitted when absent (legacy bytes) + params.world_model = {"schema_version": wms.WORLD_MODEL_SEED_VERSION, "actors": [{"agent_id": 0}]} + assert params.to_dict()["world_model"]["actors"][0]["agent_id"] == 0 + assert json.loads(params.to_json())["world_model"]["schema_version"] == wms.WORLD_MODEL_SEED_VERSION + + +def test_ontology_normalization_fills_agency_class(): + result = { + "entity_types": [ + {"name": "GovernmentMinistry", "description": "Ministries that set trade policy", "archetype": "actor"}, + {"name": "LaborUnion", "description": "", "archetype": "collective", "agency_class": "labor_union"}, + {"name": "NewsOutlet", "description": "", "archetype": "source"}, + {"name": "Chipmaker", "description": "Semiconductor manufacturers", "agency_class": "bogus"}, + ], + "edge_types": [{"name": "SANCTIONS"}], + } + OntologyGenerator._normalize_rich_schema(result) + ents = {e["name"]: e for e in result["entity_types"]} + assert ents["GovernmentMinistry"]["agency_class"] == "sovereign_government" + assert ents["LaborUnion"]["agency_class"] == "labor_union" + assert "agency_class" not in ents["NewsOutlet"] # sources are not agents + assert ents["Chipmaker"]["archetype"] == "actor" and ents["Chipmaker"]["agency_class"] == "firm" + assert all(e.get("agency_class") in AGENCY_CLASSES for e in result["entity_types"] if "agency_class" in e) + assert result["edge_types"][0]["family"] == "antagonism" + + +def test_sealed_config_carries_world_model_block(tmp_path): + from app.services.simulation_manager import build_simulation_config_seal, validate_simulation_config_seal + block = wms.build_world_model_seed(_legacy_actors(), _cfgs(False), temporal_timeline=_Timeline()) + cfg = {"simulation_id": "sim_wm", "agent_configs": _cfgs(False), + "temporal_config": {"mode": "calendar", "unit": "quarter", "n_rounds": 3, "round_dates": []}, + "world_model": block} + (tmp_path / "simulation_config.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + config_sha, manifest_sha = build_simulation_config_seal( + str(tmp_path), simulation_id="sim_wm", actor_cast_manifest_sha256=None, + actor_context_manifest_sha256=None, actor_role_manifest_sha256={}) + validate_simulation_config_seal(str(tmp_path), expected_manifest_sha256=manifest_sha, + expected_config_sha256=config_sha, expected_simulation_id="sim_wm", require=True) + reloaded = json.loads((tmp_path / "simulation_config.json").read_text(encoding="utf-8")) + assert reloaded["world_model"]["actors"][0]["agency_class"] == "firm" + # tampering with the block breaks the seal + reloaded["world_model"]["actors"][0]["agency_class"] = "sovereign_government" + (tmp_path / "simulation_config.json").write_text(json.dumps(reloaded, ensure_ascii=False, indent=2), encoding="utf-8") + with pytest.raises(ValueError): + validate_simulation_config_seal(str(tmp_path), expected_manifest_sha256=manifest_sha, + expected_config_sha256=config_sha, expected_simulation_id="sim_wm", + require=True) + + +# ----------------------------------------------------------------------------- research engine v3 rows +def _v3_actors(): + """Research engine v3 ``actors.json`` rows (deerflow_bridge/linear_research.py ACTOR EXTRACTION + TASK), shaped like the live pipe_d569f211eb14 dossier: goals / constraints / stated_vs_revealed / + memory / incentives / worldview, and none of the legacy plan or red-line keys.""" + return { + "as_of_date": "2026-10-16", + "central_question": "What is the state of US–China trade restrictions on 30 June 2027?", + "actors": [ + {"name": "Donald Trump", "type": "Person", "role": "US President; sets tariff levels personally", + "stance": "Seeks a bigger deal", "description": "Personally authorized chip sales in December 2025.", + "stated_vs_revealed": ("States 'fair and reciprocal relationship'; revealed record shows willingness " + "to override security hawks and will escalate when cornered."), + "memory": ("Escalated to 126.1 percent average tariffs in May 2025, then cut to 51.7 percent at " + "Geneva; announced a Beijing summit scheduled for April 2027."), + "influence": "high", "simulation_tier": 1, "role_class": "principal", "aliases": ["President Trump"], + "goals": ["Conclude a 'bigger deal' with China before the January 10, 2027 truce expiry", + "Preserve tariff leverage while claiming wins", + "Possibly reinstate the Affiliates Rule", + "Keep Nvidia/AMD chip sales and profit-sharing", + "Impose Section 232 duties on legacy chips"], + "constraints": ["November 3, 2026 midterm elections", + "Will not accept a deal without verified soybean purchases", + "Post-IEEPA legal limits forcing Section 301/232 instruments", + "Rules out any tariff cut below 20 percent before the midterms", + "No clear majority in the Senate for a tariff bill"], + "assets": ["Executive authority over tariffs"], + "vulnerabilities": ["Will escalate if the truce collapses"], + "incentives": [{"driver": "Political credit", "gains_if": "Framework converts to signed text", + "loses_if": "Truce collapses", "intensity": "high"}], + "worldview": {"values": ["Reciprocity"], "beliefs": ["Tariff leverage extracts concessions"], + "identity": "Dealmaker president", "frame": "Trade as transactional negotiation"}, + "risk_tolerance": "high", "salience": {"tier": 1, "score": 0.95, "basis": "Sets tariff levels"}}, + {"name": "Xi Jinping", "type": "Person", "role": "President of China", "stance": "Willing to extend truce", + "stated_vs_revealed": ("Commits to purchases and fentanyl-precursor controls; revealed practice is " + "uneven follow-through."), + "memory": "April 2025 controls followed by November 2025 suspension under deal pressure.", + "influence": "high", "simulation_tier": 1, "role_class": "principal", "aliases": [], + "goals": ["Prolong truce to stabilize economy", "Preserve rare-earth chokepoint leverage"], + "constraints": ["US imports of rare earths never recovered — leverage costs export revenue", + "Soybean pace at 'almost half' of commitment draws US criticism"], + "incentives": [{"driver": "Leverage", "gains_if": "Tariff cuts continue", "loses_if": "Re-escalation", + "intensity": "high"}], + "worldview": {"values": [], "beliefs": [], "identity": "", "frame": ""}, "risk_tolerance": "medium", + "salience": {"tier": 1, "score": 0.9, "basis": "Controls rare-earth instruments"}}, + {"name": "PIIE", "type": "Organization", "role": "Think tank", "stance": "Neutral measurement", + "stated_vs_revealed": "Neutral scholarly measurement.", "memory": "Documented the 2018-2026 chronology.", + "influence": "low", "simulation_tier": 4, "role_class": "arbiter", "aliases": [], + "goals": ["Accurate bilateral tariff measurement"], "constraints": None, "incentives": [], + "worldview": {}, "risk_tolerance": "low", "salience": {"tier": 4, "score": 0.2, "basis": "Benchmark"}}, + {"name": "商务部", "type": "Government", "role": "中国商务部", "stance": "维持暂停措施", + "stated_vs_revealed": "公开承诺扩大采购;实际执行不均", "memory": "2025年10月出台管制措施,11月暂停。", + "influence": "high", "simulation_tier": 1, "role_class": "principal", "aliases": ["MOFCOM"], + "goals": ["推动签署阶段性协议", "维持现有关税水平", "可能收紧稀土许可"], + "constraints": ["绝不接受无核查的采购承诺", "2026年11月10日法律到期需发布编号公告"], + "incentives": [], "worldview": {}, "risk_tolerance": "medium", + "salience": {"tier": 1, "score": 0.8, "basis": "管制工具"}}, + ], + "relationships": [{"source": "Donald Trump", "target": "Xi Jinping", "type": "COMPETES_WITH", + "valence": "adversarial", "polarity": -0.5, "strength": 0.7, "basis": "tariffs"}], + "quantitative_facts": [{"metric": "Average US tariff on imports from China", "value": "35.2", + "unit": "percent", "as_of_date": "2026-09-30", "source": "PIIE"}], + "forecast_inputs": {"scenarios": [{"name": "Deal deepens"}, {"name": "Truce holds"}]}, + } + + +def _v3_cfgs(): + return [{"agent_id": 0, "entity_name": "Donald Trump", "entity_type": "Person", "stance": "Seeks a bigger deal"}, + {"agent_id": 1, "entity_name": "Xi Jinping", "entity_type": "Person"}, + {"agent_id": 2, "entity_name": "PIIE", "entity_type": "Organization"}, + {"agent_id": 3, "entity_name": "商务部", "entity_type": "Government"}] + + +def test_v3_plans_come_from_stated_commitments_and_action_phrased_goals(): + trump, xi, piie, mofcom = _v3_actors()["actors"] + assert wms.derive_v3_plans(trump) == [ + "Conclude a 'bigger deal' with China before the January 10, 2027 truce expiry", # action verb + "Impose Section 232 duties on legacy chips", # action verb + "announced a Beijing summit scheduled for April 2027", # memory commitment + ] + # postures ("Preserve", "Keep"), hedges ("Possibly"), the stance-only stated clause, the revealed half, + # vulnerabilities and incentives are never promoted; "May 2025" is a month, not a hedge + assert wms.derive_v3_plans(xi) == ["Commits to purchases and fentanyl-precursor controls", + "Prolong truce to stabilize economy"] + assert wms.derive_v3_plans(piie) == [] # noun-phrase goal, no commitment + assert wms.derive_v3_plans(mofcom) == ["公开承诺扩大采购", "推动签署阶段性协议"] + assert wms.derive_v3_plans(None) == [] and wms.derive_v3_plans({"goals": "Sign the deal"}) == ["Sign the deal"] + many = {"goals": [f"Impose duty {i}" for i in range(6)]} + assert len(wms.derive_v3_plans(many)) == wms.MAX_PLANS + + +def test_v3_red_lines_come_from_non_negotiable_wording_only(): + trump, xi, piie, mofcom = _v3_actors()["actors"] + assert wms.derive_v3_red_lines(trump) == ["Will not accept a deal without verified soybean purchases", + "Rules out any tariff cut below 20 percent before the midterms"] + assert wms.derive_v3_red_lines(xi) == [] # "never recovered" is history + assert wms.derive_v3_red_lines(piie) == [] # constraints: null + assert wms.derive_v3_red_lines(mofcom) == ["绝不接受无核查的采购承诺"] # the procedural expiry is not one + refusal = {"stated_vs_revealed": "Says it will not accept chip-control floors; in fact it wavers", + "goals": ["Rule out any tariff cut before verification"]} + assert wms.derive_v3_red_lines(refusal) == ["Says it will not accept chip-control floors"] # goals are not read + assert wms.derive_v3_plans(refusal) == [] # a refusal is never a plan + assert wms.derive_v3_red_lines({"constraints": ["Might refuse to sign", "Trump's signature needed", + "No clear majority in the Senate", "Not yet ratified", + "不得不接受现有关税水平"]}) == [] # hedged / absence / "has to" + # "U.S." is an abbreviation, not a sentence end; the revealed clause after ";" stays out + ustr = {"stated_vs_revealed": "U.S. Trade Representative commits to publishing the tariff-cut list; " + "privately doubts China's follow-through"} + assert wms.derive_v3_plans(ustr) == ["U.S. Trade Representative commits to publishing the tariff-cut list"] + + +def test_build_seed_from_v3_dossier_yields_plans_and_red_lines(): + from app.services.agent_decision import build_option_menu + block = wms.build_world_model_seed(_v3_actors(), _v3_cfgs(), question="US–China trade?", temporal_timeline=_Timeline()) + actors = {a["agent_id"]: a for a in block["actors"]} + trump = actors[0] + assert trump["documented_plans"] == [ + "Conclude a 'bigger deal' with China before the January 10, 2027 truce expiry", + "Impose Section 232 duties on legacy chips", + "announced a Beijing summit scheduled for April 2027"] + assert trump["red_lines"] == ["Will not accept a deal without verified soybean purchases", + "Rules out any tariff cut below 20 percent before the midterms"] + assert trump["goals"][0].startswith("Conclude a 'bigger deal'") and trump["aliases"] == ["President Trump"] + assert actors[1]["documented_plans"][0] == "Commits to purchases and fentanyl-precursor controls" + assert actors[1]["red_lines"] == [] and actors[2]["documented_plans"] == [] and actors[2]["red_lines"] == [] + assert actors[3]["red_lines"] == ["绝不接受无核查的采购承诺"] + dumped = json.dumps(block, ensure_ascii=False) + for leak in ("will escalate when cornered", "Will escalate if the truce collapses", # revealed / vulnerability + "Tariff leverage extracts concessions", "Framework converts to signed text", # worldview / incentive + "实际执行不均"): + assert leak not in dumped, leak + promoted = json.dumps([(a["documented_plans"], a["red_lines"]) for a in block["actors"]], ensure_ascii=False) + for posture in ("Possibly reinstate", "Preserve tariff leverage", "Keep Nvidia/AMD", "维持现有关税水平"): + assert posture not in promoted, posture # goals stay goals, never plans + assert trump["rivals"] == ["Xi Jinping"] and block["state_variables"][0]["value"] == 35.2 + # the derived plans reach the option menu of the running world + world = WorldModel.from_seed(block, _v3_cfgs()) + labels = [o["label"] for o in build_option_menu(world.actors[0], world, 1)] + assert any(label.startswith("Advance your documented plan: Conclude a 'bigger deal'") for label in labels) + # explicit legacy keys still win over the derivation + dossier = _v3_actors() + dossier["actors"][0]["future_plans"] = ["FLAT PLAN"] + dossier["actors"][0]["red_lines"] = ["EXPLICIT LINE"] + row = wms.build_world_model_seed(dossier, _v3_cfgs())["actors"][0] + assert row["documented_plans"] == ["FLAT PLAN"] and row["red_lines"] == ["EXPLICIT LINE"] + + +def test_seed_reports_modelling_gaps_instead_of_inventing(): + block = { + "state_variables": [ + {"id": "a", "name": "Tariff", "value": 10, "unit": "%", "kind": "numeric", "provenance": "research", "as_of": "2026-01-01"}, + {"id": "b", "name": "Permits", "value": 3, "unit": "", "kind": "numeric", "provenance": "research", "as_of": ""}, + {"id": "c", "name": "Status", "value": "pending", "unit": "", "kind": "categorical", "provenance": "arbiter"}, + ], + "actors": [ + {"agent_id": 0, "name": "Ministry", "agency_class": "sovereign_government", "goals": ["Growth"], + "documented_plans": ["Cut tariffs"], "red_lines": []}, + {"agent_id": 1, "name": "Firm", "agency_class": "other_actor", "goals": [], "documented_plans": [], "red_lines": []}, + ], + "relationships": [], + } + gaps = wms.seed_gaps(block) + assert [(g["kind"], g["subject"]) for g in gaps] == [ + ("variable_without_unit", "Permits"), ("variable_without_as_of", "Permits"), + ("actor_unclassified", "Firm"), ("actor_without_goals", "Firm"), ("actor_without_plans", "Firm"), + ("no_relationships", "cast")] + assert all(g["note"] for g in gaps) + assert wms.seed_gaps({"state_variables": [], "actors": [], "relationships": []}) == [ + {"kind": "no_state_variables", "subject": "world", "note": "no quantitative facts to track; the world starts without numbers"}] + # the sealed block carries them and the summary line counts them + built = wms.build_world_model_seed(_legacy_actors(), _cfgs(), question="Will the permit unlock deployment?") + assert isinstance(built.get("gaps"), list) + if built["gaps"]: + assert "modelling gaps" in wms.world_model_seed_summary(built) + # the kernel keeps them across a save/load round-trip + from app.services.world_model import WorldModel + world = WorldModel.from_seed({**built, "gaps": gaps}, _cfgs()) + assert world.seed_gaps == gaps and WorldModel.from_dict(world.to_dict()).seed_gaps == gaps diff --git a/backend/tests/test_world_update.py b/backend/tests/test_world_update.py new file mode 100644 index 00000000..8655e424 --- /dev/null +++ b/backend/tests/test_world_update.py @@ -0,0 +1,375 @@ +"""Per-round world update (design §2.5): deterministic step, arbiter validation, digest. + +Offline with the conftest FakeLLMClient. Pins: variable ids in the arbiter prompt, +unknown ids / actors filtered, numeric clamp through the kernel, arbiter failure +leaves the round committed with the deterministic update, digest herding guard, +provider failures recorded precisely (class, HTTP status, provider code, snippet, +failover outcome), one fallback-provider retry through the real LLMClient, and +kind-name units ("yes boolean") never reaching a brief. +""" + +import random + +from app.services import world_update as wu +from app.services.agent_decision import AgentDecision +from app.services.world_model import WorldModel +from tests.conftest import FakeLLMClient, content_filter_error + + +def _world(): + seed = { + "question": "Will the permit unlock deployment?", + "scenarios": ["Permit granted", "Permit delayed"], + "state_variables": [ + {"name": "Regional capacity", "value": 120, "unit": "MW", "provenance": "official"}, + {"name": "Permit status", "value": "pending", "kind": "categorical"}, + ], + "actors": [ + {"agent_id": 0, "name": "Alpha Systems", "agency_class": "firm", "outcome_power": 1.5}, + {"agent_id": 1, "name": "Beta Union", "agency_class": "labor_union"}, + {"agent_id": 2, "name": "Grid Regulator", "agency_class": "regulator_agency"}, + ], + "settings": {"max_change_ratio": 0.5}, + } + cfgs = [{"agent_id": i, "entity_name": n} for i, n in enumerate(["Alpha Systems", "Beta Union", "Grid Regulator"])] + return WorldModel.from_seed(seed, cfgs) + + +_PERIOD = {"label": "2026-Q3", "period_start": "2026-07-02", "period_end": "2026-09-30"} + + +def _decisions(): + invest = {"id": "invest_capex", "label": "Commit capital: new plant", "kind": "economic", "cost": 3, "lag": 2, + "reversibility": "sticky"} + return [ + AgentDecision(0, "Alpha Systems", invest, targets=[], magnitude=0.9, + public_statement="We commit $2bn to Aurora this quarter.", scenario="Permit granted", + scenario_confidence=0.8, round_num=1, period_end="2026-09-30"), + AgentDecision(1, "Beta Union", {"id": "hold_position", "label": "Hold", "kind": "wait", "cost": 0}, + targets=[], magnitude=0.0, public_statement="", scenario="abstain", round_num=1), + AgentDecision(2, "Grid Regulator", {"id": "open_inquiry", "label": "Open an inquiry", "kind": "legal", + "cost": 2, "lag": 1}, targets=["Alpha Systems"], magnitude=0.6, + public_statement="We open a review of the Aurora filing.", status="ok", round_num=1), + ] + + +def test_deterministic_update_registers_everything(): + world = _world() + world.start_round(1, _PERIOD) + det = wu.deterministic_round_update(world, 1, _PERIOD, _decisions(), + [{"date": "2026-09-15", "content": "[2026-09-15] Permit hearing"}], + random.Random(1)) + assert det["scheduled_events"] == ["[2026-09-15] Permit hearing"] + assert det["decisions"] == 3 and len(det["actions"]) == 3 + assert world.actors[0].capacity_used == 2 and world.actors[2].capacity_used == 2 + assert world.actors[0].pending_commitments()[0]["due_round"] == 3 # lag 2 → announced commitment + assert world._relationship_between(2, 0).polarity < 0 # legal move pushes apart + + +def test_arbiter_prompt_lists_variable_ids_moves_and_cast(): + world = _world() + world.start_round(1, _PERIOD) + det = wu.deterministic_round_update(world, 1, _PERIOD, _decisions(), [], random.Random(1)) + prompt = wu.build_arbiter_prompt(world, 1, _PERIOD, _decisions(), det, world.scenarios, language="English") + assert "- regional_capacity: Regional capacity = 120 MW" in prompt + assert "- permit_status: Permit status = pending [categorical]" in prompt + assert "Alpha Systems: Commit capital: new plant (kind=economic, magnitude=0.9)" in prompt + assert "Grid Regulator: Open an inquiry" in prompt and "→ Alpha Systems" in prompt + assert "Beta Union" in prompt and "Hold" not in prompt.split("## Moves this period")[1].split("##")[0] + assert "Candidate scenarios: Permit granted; Permit delayed" in prompt + assert "Write narrative and descriptions in English." in prompt + assert "Return ONLY JSON" in prompt + + +def test_validate_filters_unknowns_and_types(): + world = _world() + world.start_round(1, _PERIOD) + update, problems = wu.validate_arbiter_update({ + "narrative": "x" * 2000, + "variable_changes": [ + {"variable_id": "regional_capacity", "delta": "15"}, + {"variable_id": "Permit status", "new_value": "under review"}, # name instead of id + {"variable_id": "ghost", "new_value": 1}, + {"variable_id": "regional_capacity", "new_value": "lots"}, + {"variable_id": "regional_capacity", "delta": "NaN"}, + ], + "new_variables": [{"name": "Strike risk", "value": "elevated", "kind": "categorical"}, + {"name": "Bad numeric", "value": "n/a", "kind": "numeric"}, + {"name": "A"}, {"name": "B"}, {"name": "C"}], + "events": [{"description": "Markets react", "kind": "weird", "actors": ["Alpha", "Zed"], "variable_ids": 1}], + "relationship_changes": [{"source": "Alpha Systems", "target": "Grid Regulator", "direction": "closer"}, + {"source": "Alpha Systems", "target": "Alpha", "direction": "closer"}, + {"source": "Alpha Systems", "target": "Beta Union", "direction": "sideways"}], + "pending_effects": [{"description": "Permit lands", "due_periods": 99, "variable_id": "permit_status", + "new_value": "granted", "probability": 1.7}, + {"description": "Capacity add", "variable_id": "regional_capacity", "delta": 30}], + "action_resolutions": [{"actor": "Alpha Systems", "outcome": "partial"}, {"actor": "Zed", "outcome": "success"}, + {"actor": "Beta Union", "outcome": "maybe"}], + "scenario_pressure": {"permit granted": "up", "Permit delayed": "sideways", "Other": "down"}, + }, world, world.scenarios) + assert len(update["narrative"]) <= wu.NARRATIVE_MAX_CHARS + assert update["variable_changes"] == [ + {"variable_id": "regional_capacity", "rationale": "", "delta": 15.0}, + {"variable_id": "permit_status", "rationale": "", "new_value": "under review"}, + ] + assert [nv["name"] for nv in update["new_variables"]] == ["Strike risk", "Bad numeric", "A"] + assert update["new_variables"][1]["kind"] == "text" + assert update["events"][0]["kind"] == "emergent" and update["events"][0]["actors"] == ["Alpha Systems"] + assert update["relationship_changes"] == [{"source": "Alpha Systems", "target": "Grid Regulator", + "direction": "closer", "rationale": ""}] + assert update["pending_effects"][0]["due_periods"] == 12 and update["pending_effects"][0]["probability"] == 1.0 + assert update["pending_effects"][1]["delta"] == 30.0 + assert update["action_resolutions"] == [{"actor": "Alpha Systems", "outcome": "partial", "rationale": ""}] + assert update["scenario_pressure"] == {"Permit granted": "up"} + joined = " | ".join(problems) + assert "unknown variable 'ghost'" in joined and "duplicate change for 'regional_capacity'" in joined + assert "unknown actor 'Zed'" in joined and "sideways" in joined and "new variables dropped" in joined + + +def test_validate_rejects_non_finite_numeric_changes(): + world = _world() + world.start_round(1, _PERIOD) + update, problems = wu.validate_arbiter_update({ + "variable_changes": [ + {"variable_id": "regional_capacity", "delta": "Infinity"}, + {"variable_id": "regional_capacity", "new_value": "NaN"}, + ], + }, world, world.scenarios) + assert update["variable_changes"] == [] + assert any("non-numeric change for numeric variable" in p for p in problems) + + +def test_run_world_round_applies_arbiter_and_builds_digest(): + world = _world() + world.start_round(1, _PERIOD) + llm = FakeLLMClient(json_responses=[{ + "narrative": "Alpha's pledge lifts expected capacity; the regulator's review slows the timetable.", + "variable_changes": [{"variable_id": "regional_capacity", "new_value": 400, "rationale": "pledge"}, + {"variable_id": "permit_status", "new_value": "under review"}], + "events": [{"description": "Bond market prices Aurora financing", "kind": "emergent", "actors": ["Alpha Systems"]}], + "pending_effects": [{"description": "Review concludes", "due_periods": 1, "variable_id": "permit_status", + "new_value": "granted", "probability": 0.5}], + "action_resolutions": [{"actor": "Alpha Systems", "outcome": "partial"}, + {"actor": "Grid Regulator", "outcome": "success"}], + "scenario_pressure": {"Permit delayed": "up"}, + }]) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=llm, rng=random.Random(3), + scenarios=world.scenarios, language="English") + assert record["arbiter_status"] == "applied" and record["arbiter_problems"] == [] + assert world.variables["regional_capacity"].value == 180.0 # 120 → clamped to +50% + assert world.variables["permit_status"].value == "under review" + assert world.committed_round == 1 + assert record["decisions"] == {"total": 3, "ok": 3, "held": 1, "failed": 0, "invalid": 0, + "language_mismatch": 0} + assert record["scenario_pressure"] == {"Permit delayed": "up"} + assert world.actors[0].last_action["resolution"] == "partial" + assert len(world.pending_effects) == 1 + digest = record["digest"] + assert digest.startswith("Alpha's pledge lifts expected capacity") + assert '- Alpha Systems: Commit capital: new plant: "We commit $2bn to Aurora this quarter."' in digest + assert "Held position: Beta Union" in digest + assert "- Regional capacity: 120 MW → 180 MW" in digest + assert "Bond market prices Aurora financing" in digest + assert "Alpha Systems — Grid Regulator: adversarial" in digest or "Grid Regulator — Alpha Systems" in digest + # herding guard: no scenario shares / percentages in agent-facing text + assert "%" not in digest and "Permit granted" not in digest + assert "2026-Q3: 3 decisions" in record["headline"] and "arbiter=applied" in record["headline"] + assert llm.calls and "Return ONLY JSON" in llm.calls[0]["messages"][0]["content"] + + +def test_run_world_round_survives_arbiter_failure_and_disabled(): + class Boom: + def chat_json(self, **kwargs): + raise RuntimeError("down") + + world = _world() + world.start_round(1, _PERIOD) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=Boom(), rng=random.Random(0), + scenarios=world.scenarios) + assert record["arbiter_status"] == "failed" and record["arbiter_problems"] == ["llm error: RuntimeError: down"] + assert world.committed_round == 1 and world.variables["regional_capacity"].value == 120.0 + assert record["decisions"]["total"] == 3 and "Alpha Systems" in record["digest"] + + world2 = _world() + world2.start_round(1, _PERIOD) + rec2 = wu.run_world_round(world2, 1, _PERIOD, _decisions(), [], llm=None, rng=random.Random(0), + scenarios=world2.scenarios, arbiter_enabled=False) + assert rec2["arbiter_status"] == "skipped" and world2.committed_round == 1 + + world3 = _world() + world3.start_round(1, _PERIOD) + rec3 = wu.run_world_round(world3, 1, _PERIOD, _decisions(), [], llm=FakeLLMClient(json_responses=["garbage"]), + rng=random.Random(0), scenarios=world3.scenarios) + assert rec3["arbiter_status"] == "invalid" and world3.committed_round == 1 + + class BadPayload: + def chat_json(self, **kwargs): + return {"events": [{"description": "Bad", "variable_ids": 1}]} + + world4 = _world() + world4.start_round(1, _PERIOD) + rec4 = wu.run_world_round(world4, 1, _PERIOD, _decisions(), [], llm=BadPayload(), rng=random.Random(0), + scenarios=world4.scenarios) + assert rec4["arbiter_status"] == "applied" + + +def test_overdue_commitments_lapse_before_new_actions(): + world = _world() + alpha = world.actors[0] + alpha.add_commitment("Missed milestone", made_round=1, due_round=1) + world.start_round(2, _PERIOD) + decision = AgentDecision(0, "Alpha Systems", {"id": "public_statement", "label": "Speak", "kind": "communication", "cost": 1}, + targets=[], magnitude=0.2, public_statement="Act now.", round_num=2) + det = wu.deterministic_round_update(world, 2, _PERIOD, [decision], [], random.Random(0)) + assert det["commitments_lapsed"] == ["Alpha Systems: Missed milestone"] + assert alpha.commitments[0]["status"] == "lapsed" + + +def test_dead_round_commits_with_no_decisions(): + world = _world() + world.start_round(2, {"label": "2026-Q4", "period_end": "2026-12-31"}) + record = wu.run_world_round(world, 2, {"label": "2026-Q4", "period_end": "2026-12-31"}, [], [], + llm=FakeLLMClient(json_responses=[{"narrative": "Quiet quarter."}]), + rng=random.Random(0), scenarios=world.scenarios) + assert record["decisions"]["total"] == 0 and record["arbiter_status"] == "applied" + assert record["digest"].startswith("Quiet quarter.") + assert world.committed_round == 2 + + +# --------------------------------------------------------------------------- +# provider failures: precise records and the fallback-provider retry +# --------------------------------------------------------------------------- +_FILTER_HEAD = "llm error: BadRequestError (HTTP 400, provider code 1301, content filter" + + +class _Filtered: + """Stand-in whose only answer is the live Zhipu content-filter rejection.""" + + def chat_json(self, **kwargs): + raise content_filter_error() + + +def test_arbiter_content_filter_failure_record_is_precise(): + """sim_d6ebcc77e22d recorded only ``llm error: BadRequestError`` for two content-filter + rejections; the record must name class, HTTP status, provider code and the message.""" + world = _world() + world.start_round(1, _PERIOD) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=_Filtered(), rng=random.Random(0), + scenarios=world.scenarios) + assert record["arbiter_status"] == "failed" and world.committed_round == 1 + [problem] = record["arbiter_problems"] + assert problem.startswith(_FILTER_HEAD + "): Error code: 400 - {'contentFilter'") + assert "'code': '1301'" in problem + assert len(problem) <= len(_FILTER_HEAD) + 2 + 2 + wu.ARBITER_ERROR_SNIPPET_CHARS + assert world.variables["regional_capacity"].value == 120.0 # no fabricated numeric effect + assert "arbiter=failed" in record["headline"] + + +def test_arbiter_content_filter_recovers_through_fallback_provider(scripted_failover): + """The arbiter goes through the backend LLMClient: a content-filter rejection is retried ONCE + on the configured fallback provider and the round is applied from its answer.""" + payload = {"narrative": "Fallback arbiter resolved the period.", + "variable_changes": [{"variable_id": "permit_status", "new_value": "under review"}], + "action_resolutions": [{"actor": "Alpha Systems", "outcome": "partial"}]} + llm, transports = scripted_failover(content_filter_error(), payload) + world = _world() + world.start_round(1, _PERIOD) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=llm, rng=random.Random(0), + scenarios=world.scenarios) + assert record["arbiter_status"] == "applied" and record["arbiter_problems"] == [] + assert transports == [("glm", False), ("kimi", True)] # one primary attempt, one fallback retry + assert world.variables["permit_status"].value == "under review" + assert world.actors[0].last_action["resolution"] == "partial" + assert record["digest"].startswith("Fallback arbiter resolved the period.") + + +def test_arbiter_records_fallback_outcome_when_both_providers_fail(scripted_failover): + llm, transports = scripted_failover(content_filter_error(), fallback_error=content_filter_error("assistant")) + world = _world() + world.start_round(1, _PERIOD) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=llm, rng=random.Random(0), + scenarios=world.scenarios) + assert record["arbiter_status"] == "failed" and world.committed_round == 1 + [problem] = record["arbiter_problems"] + assert problem.startswith(_FILTER_HEAD + "; fallback kimi: failed (BadRequestError (HTTP 400, provider code 1301, " + "content filter): Error code: 400") + assert transports == [("glm", False), ("kimi", True)] # degrade-safe: no further attempts + + unconfigured, transports2 = scripted_failover(content_filter_error(), fallback_provider=None) + world2 = _world() + world2.start_round(1, _PERIOD) + rec2 = wu.run_world_round(world2, 1, _PERIOD, _decisions(), [], llm=unconfigured, rng=random.Random(0), + scenarios=world2.scenarios) + assert rec2["arbiter_status"] == "failed" + assert rec2["arbiter_problems"][0].startswith(_FILTER_HEAD + "; fallback: not configured (LLM_FALLBACK_PROVIDER unset)") + assert transports2 == [("glm", False)] + + +# --------------------------------------------------------------------------- +# units: a kind name is never a unit +# --------------------------------------------------------------------------- +def test_validate_blanks_kind_named_units_on_new_variables(): + world = _world() + world.start_round(1, _PERIOD) + update, _ = wu.validate_arbiter_update({"new_variables": [ + {"name": "Truce talks opened", "value": True, "unit": "boolean", "kind": "boolean"}, + {"name": "Deadline agreed", "value": "2027-03-10", "unit": "date", "kind": "date"}, + {"name": "Licence backlog", "value": 12, "unit": "numeric", "kind": "numeric"}, + ]}, world, world.scenarios) + assert [(nv["kind"], nv["unit"]) for nv in update["new_variables"]] == [("boolean", ""), ("text", ""), ("numeric", "")] + update2, _ = wu.validate_arbiter_update({"new_variables": [ + {"name": "Tariff floor", "value": 10, "unit": "%", "kind": "numeric"}, + {"name": "Status note", "value": "pending", "unit": "USD", "kind": "text"}, # text facts carry no unit + ]}, world, world.scenarios) + assert [(nv["kind"], nv["unit"]) for nv in update2["new_variables"]] == [("numeric", "%"), ("text", "")] + + +def test_briefs_never_print_a_kind_name_as_unit(): + world = _world() + world.start_round(1, _PERIOD) + llm = FakeLLMClient(json_responses=[{"narrative": "Talks opened.", "new_variables": [ + {"name": "Truce talks opened", "value": True, "unit": "boolean", "kind": "boolean"}, + {"name": "Deadline agreed", "value": "2027-03-10", "unit": "date", "kind": "date"}]}]) + record = wu.run_world_round(world, 1, _PERIOD, _decisions(), [], llm=llm, rng=random.Random(0), + scenarios=world.scenarios) + assert record["arbiter_status"] == "applied" + assert world.variables["truce_talks_opened"].unit == "" and world.variables["deadline_agreed"].unit == "" + assert "- New: Truce talks opened = yes" in record["digest"] + assert "- New: Deadline agreed = 2027-03-10" in record["digest"] + brief = world.render_public_brief(upto_round=1, previous=record["snapshot_before"]) + assert "- Truce talks opened: yes" in brief and "- Deadline agreed: 2027-03-10" in brief + world.start_round(2, {"label": "2026-Q4", "period_end": "2026-12-31"}) + prompt = wu.build_arbiter_prompt(world, 2, world.period, [], {}, world.scenarios) + assert "Truce talks opened = yes [boolean]" in prompt and "Deadline agreed = 2027-03-10 [text]" in prompt + for text in (record["digest"], brief, prompt): + assert "yes boolean" not in text and "2027-03-10 date" not in text + + +def test_behaviour_metrics_count_moves_that_answer_last_period(): + world = _world() + names = [a.name for a in world.actors.values()] + world.start_round(1, {"label": "P1"}) + var = next(iter(world.variables.values())) + var.set_value(99, round_num=1, source="arbiter", cause="shock") + world.register_decisions(1, [{"agent_id": list(world.actors)[0], "option": {"id": "impose_measure", "label": "Impose", "kind": "policy", "cost": 1, "lag": 0}, + "targets": [names[1]], "magnitude": 0.5, "public_statement": "Imposed.", "status": "ok"}]) + world.commit_round(1) + assert wu.behaviour_metrics(world, 1, [])["responsive_share"] is None # no prior period + world.start_round(2, {"label": "P2"}) + a0, a1 = list(world.actors)[:2] + rows = [ + {"agent_id": a1, "actor_name": names[1], "option": {"id": "public_statement", "label": "Say", "kind": "communication", "cost": 1}, + "public_statement": f"After {var.name} jumped we answer {names[0]} in kind.", "private_rationale": "", "status": "ok"}, + {"agent_id": a0, "actor_name": names[0], "option": {"id": "subsidize", "label": "Subsidise", "kind": "economic", "cost": 2}, + "public_statement": "A new programme.", "private_rationale": "unrelated", "status": "ok"}, + {"agent_id": a0, "actor_name": names[0], "option": {"id": "hold_position", "label": "Hold", "kind": "wait", "cost": 0}, + "public_statement": "", "status": "ok"}, + {"agent_id": a1, "actor_name": names[1], "option": {"id": "accept:i1_1", "label": "Accept", "kind": "coalition", "cost": 1}, + "public_statement": "Accepted.", "status": "ok"}, + ] + m = wu.behaviour_metrics(world, 2, rows) + assert m == {"moves": 3, "responsive_moves": 2, "offers_answered": 1, "responsive_share": 0.667, + "signals_last_period": m["signals_last_period"]} + assert m["signals_last_period"] >= 2 + record = wu.run_world_round(world, 2, {"label": "P2"}, rows[:2], [], llm=None, rng=None, arbiter_enabled=False) + assert record["behaviour"]["moves"] == 2 and record["behaviour"]["responsive_moves"] == 1 diff --git a/docs/handoff/reflexive-world-state/design.md b/docs/handoff/reflexive-world-state/design.md new file mode 100644 index 00000000..dd58c1a1 --- /dev/null +++ b/docs/handoff/reflexive-world-state/design.md @@ -0,0 +1,265 @@ +# Reflexive world-state model for the multi-agent simulation + +**Branch:** `claude-reflexive-world-state-model` (off `main` 8852ce1) +**Status:** design frozen 2026-10-16; implementation in this branch +**Owner record:** [handoff.md](handoff.md) + +The words MUST, SHOULD and MAY are used as in RFC 2119. + +## 1. Problem + +The simulation stage runs OASIS (Twitter + Reddit) in calendar mode: one round is one +calendar period, every active agent writes one "period communiqué" post, a reaction step +lets agents reply, and a shared `WorldState` evolves. That `WorldState` is only a +probability distribution over the forecast scenarios, moved by a batched elicitation +("which scenario does each actor push toward?"). Agents never see a concrete world: there +are no facts that change, no consequences of an announced investment or sanction, no +pending commitments, no alliances that shift because of what somebody did. The feedback +loop between rounds is a 900-character qualitative digest of posts. The simulation +therefore measures discourse, not an evolving world. + +The user asked for a reflexive simulation: an explicit world state every round, actors +acting on it with realistic, motivated option sets (goals, values, beliefs, incentives, +alliances, competition, constraints), the world changing in response to those actions, and +actors responding to the changed world. + +## 2. Architecture + +### 2.1 Round protocol (calendar mode, `SIM_WORLD_MODEL=true`) + +``` +round r (one calendar period) + 1. begin_round barrier: wait until world update r-1 is committed + -> public WORLD STATE brief for r (facts, moves, consequences, + alliances, pending items; never scenario probabilities) + 2. decision step ONE structured LLM call per deciding actor (principals + active + named actors, capped): option menu grounded in the actor's role + contract and the current world -> {option, targets, magnitude, + public statement, commitment, alliance moves, scenario pushed} + 3. publish each platform posts the public statement (ManualAction CREATE_POST, + tagged is_world_decision) and writes a private USER memory note; + non-deciding active agents keep the OASIS LLMAction post + 4. discourse reaction step (replies / endorsements) unchanged + 5. deliver platform hands its round actions + fired events to the coordinator + 6. world update when every live platform delivered r (or heartbeat for a dead + round): deterministic kernel update (capacity, commitments, + relationship nudges, due effects, scheduled events) + ONE arbiter + LLM call that resolves contested actions, moves state variables, + emits emergent events and delayed effects (validated and bounded); + scenario-share WorldState stepped from the actors' scenario + commitments (unchanged consumer contract); artifacts persisted; + next brief rendered; barrier r released +``` + +Hours mode (no `temporal_config`) and `SIM_WORLD_MODEL=false` MUST stay byte-identical to +the current behaviour. The feature is presence-keyed: the runner enters world mode only +when `simulation_config.json` carries a `world_model` block AND the flag is on. + +### 2.2 Modules + +| Module | Role | Purity | +|---|---|---| +| `backend/app/services/world_model.py` | Kernel: `WorldModel` (state variables, actor states, relationships, pending effects, events), apply/advance/snapshot/diff, public and private brief renderers | pure, seeded RNG injected | +| `backend/app/services/agent_decision.py` | Agency classes, action catalog, option menu, decision prompt, decision parsing/validation, memory note | pure except the injected `llm.chat_json` | +| `backend/app/services/world_update.py` | Deterministic round update, arbiter prompt, arbiter output validation, round digest | pure except the injected `llm.chat_json` | +| `backend/app/services/world_model_seed.py` | PREPARE-time `world_model` block from research artifacts (quantitative facts, official data, actor intelligence, relationships, scheduled events) | pure | +| `backend/app/services/agent_decision.py` (`infer_agency_class`) | agency-class inference shared by ontology normalisation, the seed and the decision step (kept in services so utils never imports services) | pure | +| `backend/app/services/ontology_generator.py` | rich-schema `agency_class` / `levers` metadata and normalisation | prompt + pure | +| `backend/app/services/simulation_config_generator.py` | emits `world_model` block (before sealing) and per-agent `agency_class` / `archetype` | existing | +| `backend/scripts/run_parallel_simulation.py` | `_ReflexiveWorldCoordinator`: shared per-sim-dir round barrier, decision step, world update, artifacts | runtime | +| `backend/app/services/simulation_runner.py` | run-state projection of world rounds, run_summary `world_model` block, stale-artifact rotation | existing | +| `backend/app/api/simulation.py` | `GET /api/simulation//world-state` | existing | +| `frontend/src/components/research/WorldStatePanel.vue`, `frontend/src/utils/worldState.js` | live world-state panel inside the simulation tab | new | +| `backend/app/services/report_agent.py`, `report_visualizer.py` | world-evolution block in the diagnostic signal pack; world-variable chart | existing | + +### 2.3 World model (kernel) + +`WorldModel` holds: + +- `variables`: `{id: StateVariable}` with `name`, `value`, `unit`, `kind` + (numeric / categorical / boolean / text), `as_of`, `provenance` + (research / official / seed / arbiter / effect), bounded `history`. +- `actors`: `{agent_id: ActorState}` with `name`, `agency_class`, `archetype`, + `capacity_per_round`, `capacity_used`, `outcome_power` (declared only), `posture`, + `documented_plans`, `red_lines`, `commitments` (pending, with `due_round`), `last_action`. +- `relationships`: `{(a, b): Relationship}` with `kind`, `valence`, `polarity`, + `strength`, `last_changed_round`, `basis`. +- `pending_effects`: delayed consequences `{due_round, description, variable_id, + op, value, probability, source}` resolved with the seeded RNG when due. +- `events`: per-round `WorldEvent` rows (scheduled / action / consequence / emergent / + resolution) with the actors involved. +- `round`, `period`, `scenario_shares` (mirror of the scenario `WorldState`, for the + record only; never rendered into agent-facing text). + +Pure operations: `register_decisions`, `apply_arbiter_update`, `advance`, `snapshot`, +`diff`, `render_public_brief`, `render_private_brief`, `to_dict` / `from_dict`. + +Invariants: + +- I-1 Agent-facing text never contains scenario shares or percentages derived from them + (herding guard, inherited from `world_delta`). Variables with a `%` unit are world + facts and are allowed. +- I-2 Every change to a variable carries a cause (`round`, `source`, `rationale`) in its + history; numeric changes from the arbiter are clamped to + `SIM_WORLD_MAX_CHANGE_RATIO` of the current magnitude per round. +- I-3 Unknown outcome power stays unknown. Capacity per round comes from the agency class, + never from visibility (`influence_weight`). +- I-4 The kernel never calls an LLM, never does I/O and never reads `Config`. + +### 2.4 Decision step + +- Deciding roster per round: all `cadence == "principal"` agents plus the active sampled + named (non-audience) agents, capped at `SIM_WORLD_DECISION_MAX_ACTORS` (default 24), + principals first, then by influence weight. +- Option menu (deterministic): response options (to actions / events that targeted the + actor last period), documented options (the actor's researched plans and likely + actions), agency-class options from the catalog filtered by remaining capacity and + reversibility, and `hold_position`. At most 10 options. +- Prompt: system message = the actor's existing sealed role brief (read from the live + OASIS agent, never modified); user message = WORLD CLOCK line, public world brief, + private brief (own commitments, consequences aimed at it, capacity), the option menu, + the candidate scenarios (names only) and the JSON contract. +- Output validated: option id must be in the menu (or `custom` with a catalogued kind), + targets resolved to cast names, magnitude clamped, statement bounded, scenario name + validated or `abstain`. Any failure degrades to `hold_position` with a typed status + (`failed` / `invalid`) so a dead decision step can never masquerade as "everybody + waited". +- One decision per actor per round shared across platforms. Both platforms publish the + same statement; each writes a `# YOUR MOVE THIS PERIOD` USER memory note so the + reaction step and later rounds stay consistent. + +### 2.5 World update + +Deterministic (always): consume capacity, register commitments (due_round = r + lag), +nudge relationships (coalition moves closer / antagonistic moves farther, bounded), +fire due pending effects (probability roll with the seeded RNG), record scheduled events, +advance the round counter. + +Arbiter (one LLM call, `SIM_WORLD_ARBITER=true`): receives the world snapshot brief, the +round's decisions (option, targets, magnitude, statement), fired events and the +deterministic effects, and returns `variable_changes`, `new_variables` (bounded count), +`events`, `relationship_changes`, `pending_effects`, `action_resolutions`, a short +`narrative`, and `scenario_pressure` (direction words only). The update is validated +against the variable registry and the cast before being applied. On failure the round is +committed with the deterministic update only and `arbiter_status=failed`. + +Scenario shares: commitments are the deciding actors' `scenario_commitment`s weighted by +declared outcome power (`commitments_from_decisions`), stepped through the existing +`WorldState` with the calendar inertia and entropy floor; `round_status` follows the +Foglamp typed statuses. `world_state_trajectory.json` (schema v3), `world_digest.jsonl` +and `decisions.jsonl` keep their contract; the trajectory gains an additive +`world_model` summary. + +### 2.6 Artifacts (simulation directory) + +| File | Content | +|---|---| +| `world_state.json` | latest committed `WorldModel.to_dict()` plus `committed_round`, `brief` | +| `world_state_history.jsonl` | one row per committed round: period, statuses, diff, arbiter narrative, decisions summary | +| `world_actions.jsonl` | one row per structured decision (round, actor, option, targets, magnitude, statement, status) | +| `world_state_trajectory.json`, `world_digest.jsonl`, `decisions.jsonl` | unchanged scenario-share contract | +| `twitter|reddit/actions.jsonl` | principals' statements are `CREATE_POST` rows tagged `is_world_decision`, `option_id`, `action_kind`; a `world_update` event row per committed round carries a one-line headline | + +### 2.7 PREPARE-time seed (`world_model` block) + +Built by `world_model_seed.build_world_model_seed` inside `generate_config` (calendar +mode) before the config is sealed: + +- `state_variables`: numeric/categorical facts parsed from `quantitative_facts` and the + bound official-data snapshot (bounded by `SIM_WORLD_MAX_VARIABLES`), each with unit, + as-of date and provenance. +- `actors`: per cast member `agent_id`, `name`, `agency_class` (dossier value, ontology + value, or inferred from type/role text), `archetype`, `capacity_per_round` (class + default), `outcome_power` (declared only), `documented_plans`, `red_lines`, + `allies`, `rivals`. +- `relationships`: valenced edges from `relationships[]` restricted to the cast. +- `settings`: arbiter on/off, max change ratio, barrier timeout. + +Canonical actor-intelligence/v1 dossiers: plans and red lines come from the validated +context pack rows (documented evidence only); the public brief only ever shows facts the +world has produced or public research facts; analyst inference never becomes world fact. + +### 2.8 Ontology + +The rich-schema addendum asks for `agency_class` and `levers` on actor / collective +entity types; normalisation fills a missing `agency_class` from the type name. The +general-forecast prompt tells the designer that entity types must support an evolving +actor world (levers, constraints, decision rights). This is additive metadata, like +`archetype`. + +### 2.9 Downstream + +- Run state: `world_model_round`, `world_headline` projected from the `world_update` + event rows; `run_summary.json` gains a `world_model` block (rounds committed, variables, + events, arbiter failures, decision coverage). +- API: `GET /api/simulation//world-state` returns the latest state and a bounded + history, with the same containment/symlink/size posture as the trajectory endpoint. +- UI: a World State panel in the simulation tab (period headline, key facts with deltas, + latest moves, consequences, alliance shifts), polled with the existing refresh. +- Report: `_world_evolution_block` adds variable trajectories, decisive moves and + alliance shifts to the diagnostic signal pack under the elicited-model-projection + label (`forecast_effect` unchanged); a "World State Variables" chart is registered in + the report visualizer. +- Pipeline: `_log_decision_channel_outcome` records the world-model summary. + +## 3. Acceptance scenarios + +| Id | Scenario | Evidence | +|---|---|---| +| A1 | A principal's round-1 decision (e.g. tariff increase) appears as a consequence in the round-2 public brief handed to every active agent (reflexivity) | `test_world_model_loop.py::test_world_model_round_loop_is_reflexive` | +| A2 | Each deciding actor is prompted at most once per round even with two platforms; the second platform reuses the shared decision | `test_world_model_loop.py::test_two_platforms_share_decisions_and_respect_barrier` | +| A3 | A platform never starts round r+1 before the world update for r is committed | same test, ordering assertions | +| A4 | Arbiter failure: the round still commits with the deterministic update and `arbiter_status=failed`; the loop never crashes | `test_world_model_loop.py::test_arbiter_failure_round_commits_deterministically` | +| A5 | Invalid decision JSON degrades to `hold_position` with status `invalid`; unknown option ids are rejected | `test_agent_decision.py` | +| A6 | Public brief contains no scenario share percentages; private brief lists the actor's own commitments | `test_world_model.py` | +| A7 | Hours mode and `SIM_WORLD_MODEL=false` produce no world-model artifacts and unchanged events | `test_world_model_loop.py::test_world_model_off_falls_back_to_legacy_inband`, `::test_hours_mode_ignores_world_model_block`, existing `test_calendar_round_loop.py` | +| A8 | `world_model` block is generated from a dossier with quantitative facts and relationships and survives sealing | `test_world_model_seed.py` | +| A9 | API endpoint containment / 404 / happy path; run_summary, run-state projection, report block, chart, orchestrator summary | `test_world_model_downstream.py` | +| A10 | Full backend suite stays green; resume never re-decides a committed round | pytest run recorded in handoff; `test_world_model_loop.py::test_resume_skips_committed_world_rounds` | + +## 4. Out of scope (recorded, not hidden) + +- The standalone `run_twitter_simulation.py` / `run_reddit_simulation.py` scripts never + supported calendar mode; the world model targets `run_parallel_simulation.py` (both + platforms, or `--twitter-only` / `--reddit-only`). +- Promotion of world-model outputs into published probabilities stays behind the Foglamp + `SIMULATION_FORECAST_EFFECT` policy (`diagnostic_only`). +- The Codex engine on `codex/reflexive-world-simulation` (`world-v1`, a separate opt-in + engine that replaces the social simulation) is not merged wholesale: its own delivery keeps + it opt-in after its live behaviour gate. Section 2.10 lists what was ported from it. + +## 2.10 Ported from the Codex `world-v1` engine (2026-10-17) + +Studied: `docs/handoff/reflexive-world-simulation/{ARCHITECTURE,DELIVERY,contracts,plan-review, +runtime-input,policy-implementation,store-implementation}.md` and the package +`backend/app/services/world_simulation/`. Kept the integrated model as the base (it runs inside +the social simulation, is live-validated and feeds the report and the dashboard). Ported: + +- **Consent rule (offers → answers → obligations → breach).** `world_model.Interaction`: a move + of a proposing option (`open_talks`, `form_alliance`, `signal_concession`, `sign_agreement`, + `partnership_deal`, `bargain`, `convene`, `program_decision`) aimed at another actor opens an + offer with exact `terms` (new decision field); the recipient's menu starts with + `accept:/counter:/reject:/defer:`; acceptance binds both parties with commitments + (`source=agreement:`, `counterparty`), pulls the relationship closer; rejection pushes it + apart; a counter opens a counter-offer back; a deferral moves the deadline one period (twice at + most); unanswered offers lapse after `INTERACTION_TTL_PERIODS`; a lapsed obligation or an + explicit `break_alliance` is a breach (reputation −0.15, relationship −0.3, obligations + breached/released). Offers appear in both briefs, the digest, the arbiter prompt (which must + never assume consent), the round record (`offers_opened/answered/lapsed`, `breaches`), the + run summary (`offers` counts), the `/world-state` API (`interactions`), the panel card and the + report block. Tests: `tests/test_world_interactions.py`. +- **Modelling gaps instead of invention.** `world_model_seed.seed_gaps` lists what the research + could not ground (numeric facts without units or as-of dates, actors without goals / plans / + an agency class, a cast without relationships, a world without facts); the sealed block, the + kernel (`seed_gaps`), the run summary, the API and the panel badge carry them. +- **Behaviour diagnostics.** `world_update.behaviour_metrics`: per round, the share of moves + that answered an offer or whose statement / rationale names a fact that changed or an actor + that moved last period (a textual diagnostic, not a realism verdict); aggregated in the run + summary (`behaviour`), shown as a panel badge and a report line. + +Not ported, with reasons: the SQLite fenced store and exact-decimal replay (the JSON artifacts +plus resume guards already give replay without re-deciding), the strict budgeted HTTP policy +(the pipeline's telemetry/budget and the decision concurrency caps cover it), the compiler's +typed mechanism catalogue (it only admits eight pre-declared action kinds and fails the whole +world on any gap; the integrated model keeps the agency-class catalogue plus a bounded arbiter), +the preregistered evaluation harness (tied to Codex's evidence workflow and frozen fixtures). diff --git a/docs/handoff/reflexive-world-state/handoff.md b/docs/handoff/reflexive-world-state/handoff.md new file mode 100644 index 00000000..23b7d907 --- /dev/null +++ b/docs/handoff/reflexive-world-state/handoff.md @@ -0,0 +1,124 @@ +# Handoff.md — reflexive world-state simulation + +**Last Updated (UTC):** 2026-10-16T20:05Z +**Status:** Complete — live-validated on `pipe_d569f211eb14` / `sim_d6ebcc77e22d` (2026-10-16T16:02–17:21Z); `feature_reflexive_world_state_model.passes = true`; the same pipeline ran to `completed` with a published report on resume #2 (`report_0f2e07d62a34`, 2026-10-16T19:34–19:57Z) after the report-stage content-filter fixes +**Current Focus:** no open acceptance item; follow-ups in §9 (bilingual translation pass vs. the provider filter). + +The words MUST, SHOULD and MAY are used as in RFC 2119. + +## 1) Request & Context +- **User's request (2026-10-16, paraphrased):** make the multi-agent simulation realistic, dynamic and reflexive. Every round MUST have a world state; the world state MUST change in response to the agents' actions; agents MUST see the changed world and respond to it. Agents MUST behave realistically with option sets motivated by goals, values, beliefs, incentives, disincentives, alliances, competition and constraints. Significant re-architecture is allowed, including the actor ontology and new agent steps such as a world-state update step. Work on a new branch `claude-reflexive-world-state-model` off `main`. +- **Operational constraints / environment:** backend tests are offline (fake LLM, fake OASIS env). The Reddit canonical system message is byte-attested, so per-round context MUST be injected as USER memory notes, not system-message edits. `simulation_config.json` is sealed after PREPARE; new config fields MUST be produced inside `generate_config` (before sealing). Hours mode MUST stay byte-identical. The primary checkout carried unrelated uncommitted edits (forecast_extractor, report_lint, pyproject, uv.lock, deleted demo assets, root handoff.md, `newskills/`); they stay uncommitted and untouched on this branch. +- **Guidelines / preferences to honor:** degrade-safe runtime (no failure breaks the round loop); herding guard (no scenario shares in agent-facing text); world-state outputs remain labeled elicited model projections and never move published probabilities (`SIMULATION_FORECAST_EFFECT`); README EN/ZH stay line-aligned. +- **Scope boundaries (explicit non-goals):** single-platform standalone scripts (no calendar support today); promotion of simulation outputs into probabilities; the parallel Codex planning branch `codex/reflexive-world-simulation`. +- **Changes since start (dated deltas):** none to the request. 2026-10-16T14:00Z: the agency classifier lives in `agent_decision.py` rather than `utils/actors.py` so utils never imports services (design.md amended). + +## 2) Requirements → Acceptance Checks (traceable) +The acceptance matrix (A1–A10) lives in [design.md §3](design.md#3-acceptance-scenarios); evidence per id is in §8 below. + +## 3) Plan & Decomposition (with rationale) +- **Critical path narrative:** pure kernel first (testable without the runner), then decision/update modules against it, then the runner barrier (riskiest: two concurrent platform loops must become lockstep without deadlock), then PREPARE seeding, then consumers. Each step was covered by offline tests before the next started. +- Steps 1–7 as listed in the ledger below; all done except the final commit and the live run. + +## 4) To-Do & Progress Ledger +- [x] Survey current simulation architecture — evidence: design.md §1–2. +- [x] Branch `claude-reflexive-world-state-model` off main 8852ce1. +- [x] Design frozen — design.md. +- [x] Step 1 kernel `backend/app/services/world_model.py` — evidence: `tests/test_world_model.py` (18 tests). +- [x] Step 2 decision step `backend/app/services/agent_decision.py` — evidence: `tests/test_agent_decision.py` (27 tests incl. the 15-case agency-class table). +- [x] Step 3 world update `backend/app/services/world_update.py` — evidence: `tests/test_world_update.py` (6 tests). +- [x] Step 4 runner wiring `_ReflexiveWorldCoordinator` + loop changes in `backend/scripts/run_parallel_simulation.py`, `log_world_update` in `action_logger.py` — evidence: `tests/test_world_model_loop.py` (6 tests driving the real loop) and the unchanged `tests/test_calendar_round_loop.py` (7) + `tests/test_sim_reaction_phase.py` (20). +- [x] Step 5 PREPARE seed `world_model_seed.py`, config generator emission (`world_model` block, `AgentActivityConfig.agency_class/archetype/outcome_power`), config flags, ontology `agency_class`/`levers` metadata + normalisation — evidence: `tests/test_world_model_seed.py` (9 tests). +- [x] Step 6 downstream: run-state projection, `run_summary.world_model`, `GET /api/simulation//world-state`, report `_world_evolution_block` + `world_variables` chart (EN/ZH titles), orchestrator summary, frontend `WorldStatePanel.vue` + `utils/worldState.js`, `.env.example`, README EN/ZH — evidence: `tests/test_world_model_downstream.py` (10), `frontend/tests/worldState.test.mjs` (5), `test_report_zh_presentation.py` parity. +- [x] Step 7 final verification + commit — full suite: 2 failed (pre-existing demo assets), 6229 passed, 22 skipped, 11 xfailed in 373 s; committed (see §10). +- [x] Live validation run (paid; owner-approved 2026-10-17 00:01 +08) — evidence: §7 "Live run" and §8 "Live run"; records in agent-progress.txt; feature flag set. + +## 5) Findings, Decisions, Assumptions +- **Finding:** the previous "world state" was a scenario-share distribution evolved by one batched elicitation per round; the only inter-round feedback was a 900-character qualitative post digest. Agents never observed consequences, so the simulation measured discourse. +- **Finding:** `_InbandWorldEvolution` ordered updates by platform watermarks but was not a causal barrier: a fast platform could start round r+1 before round r was stepped. The coordinator's `begin_round(r+1)` now awaits the commit of r (barrier timeout `SIM_WORLD_BARRIER_TIMEOUT_S`, default 1800 s; a stalled platform is marked and the round commits with what was delivered). +- **Decision:** deciding actors make ONE structured decision per round that REPLACES their OASIS post call; the statement is published as a tagged `CREATE_POST` ManualAction on both platforms, so cost stays roughly neutral and every actor decides once per round. Actors whose decision failed or was invalid fall back to the organic post; holders post nothing but still take part in the reaction step. Alternative (second call on top of the OASIS post) rejected for cost and inconsistency. +- **Decision:** the arbiter call REPLACES the per-round scenario elicitation; scenario commitments come from the decisions themselves (each actor names the scenario its move pushes toward or abstains). Audience/sampled agents without a decision contribute no commitment. +- **Decision:** deterministic updates never fabricate numeric effects; variables move only through the validated arbiter output (clamped by `SIM_WORLD_MAX_CHANGE_RATIO`) or declared pending effects. When the arbiter is off or fails, facts hold and the round still commits. +- **Decision (changed during testing):** a commitment stays pending until due; it is honoured by acting (any non-hold move) in the round it falls due and lapses the round after otherwise. An arbiter "success" on the action no longer fulfils forward commitments (it only nudges reputation). Reason: the first loop run showed the pledge disappearing from the brief the moment the move succeeded. +- **Decision:** `agency_class` is inferred deterministically (dossier value → ontology value → type/role text with word-boundary matching); the ontology addendum asks the LLM for `agency_class` and `levers` on actor/collective types and normalisation fills gaps. +- **Decision:** world-model artifacts are per-run and rotate on rerun (`world_state.json`, `world_state_history.jsonl`, `world_actions.jsonl`, and the previously un-rotated `world_digest.jsonl`). +- **Assumption:** rounds complete within the barrier timeout. Falsification: covered only by the two-platform loop test (no timeout path exercised live); the timeout branch marks the missing platform stalled and continues — monitor in the live run. +- **Finding (live run):** the seed covered the research well — 24 numeric state variables (all `provenance=research`, with units and as-of dates), 7 actors with inferred classes (3 sovereign_government, 2 individual_leader, 1 firm, 1 legislature), 7 valenced relationships — but `documented_plans` and `red_lines` were empty for every actor: the v3 `actors.json` carries `goals`, `incentives`, `constraints`, `stated_vs_revealed`, `vulnerabilities`, never the `future_plans` / `likely_actions` / `red_lines` / `non_negotiables` keys the legacy branch of `world_model_seed` reads. Option menus therefore came from response options + class levers only; they were still varied (12 distinct non-hold option ids over 50 decisions). +- **Finding (live run):** the arbiter grows the world: it added 2 facts per applied round (14 in total: truce status, summit scheduling, Affiliates Rule in force, purchase framework status, MOFCOM suspension status…), 5 of them non-numeric. Numeric facts moved only when something binding happened (first change in round 3), through declared delayed effects and arbiter changes, always with a cause and inside the 0.5 change-ratio clamp. +- **Finding (live run):** provider content filtering is a real failure mode on political topics: Zhipu glm-5.3 returned HTTP 400 code 1301 (content filter) for 2 of 9 arbiter calls, 2 graph chunks, 2 market-relevance scorings, one report section (retry succeeded) and the report's binary-forecast extraction. The world loop degraded exactly as designed (see §7), the report did not (publish gate, unrelated to this branch). +- **Assumption:** roster/sampling differences between platforms are acceptable: the first platform to reach `decide(r)` fixes the round's deciding roster from its own active agents; the other platform publishes the same decisions and lets its extra sampled agents post organically. + +## 6) Issues, Mistakes, Recoveries +- Parallel Bash calls with `cd` raced on the shell working directory during the survey → spurious "No such file" errors; recovered with absolute paths. +- Agency-class hints matched substrings ("nato" inside "senator", `MediaOutlet` hiding a word boundary) → word-boundary matching for ASCII hints plus camelCase/underscore splitting; pinned by the 15-case table and `test_infer_agency_class_word_boundaries_and_archetype`. +- Kernel auto-fulfilled forward commitments on action success → changed to "honoured by acting when due"; pinned by `test_due_commitment_is_honoured_by_acting_not_by_resolution`. +- Due-date labels rendered as "period N" → persisted `period_labels` map pre-filled from the calendar; pinned by the loop test's "(due 2026-Q4)" assertion. +- A resumed coordinator reported `committed_round=0` and would have re-decided committed rounds → resume guards in `decide`/`deliver`/`heartbeat` and `_max_round` initialised from disk; pinned by `test_resume_skips_committed_world_rounds`. +- Canonical cast rows carry the placeholder entity type `Actor` → classifier defers to the dossier's own type; official series without units excluded like the snapshot does; pinned in `test_world_model_seed.py`. +- New chart title lacked EN→ZH entries (backend table and frontend `vizManifest.js`) → added; pinned by `test_report_zh_presentation.py`. +- Full-suite summary line vanished because pyproject already passes `-q` (my extra `-q` made it `-qq`) → rerun without the flag. +- **Live run, open (follow-ups in §9):** (a) rounds 5–6 `arbiter_status=failed` with `arbiter_problems=["llm error: BadRequestError"]` — the Zhipu content filter (code 1301); the history row keeps only the exception type, so the provider code/message is not in the artifacts; (b) 1 of 50 public statements (MOFCOM, round 4) was written in Chinese in an English run although the decision prompt carries `Write public_statement in English.` — the validator checks structure, not language; (c) arbiter-created non-numeric facts carry their kind name as the unit (`unit="boolean"/"text"/"date"`), so briefs and the report block print "yes boolean" / "scheduled November 2026 text"; (d) the `world_variables` chart starts each series at its first change and plots intra-period duplicates (two Dec-2026 points for one variable), with no seed point at the as-of date; (e) the coordinator's per-round log lines (`round N decisions: …`, `world update committed: …`) are print-only in the runner subprocess and stayed buffered while the runner idled in wait-command mode after the loop, so `simulation.log` holds none of them (0 platform-prefixed lines after the run); (f) validator rejections worked as intended — round 3 `unknown variable 'average_us_tariff_on_chinese_imports'`, round 7 `unknown relationship direction 'flat'` — but the arbiter prompt could list the exact ids and the allowed directions to avoid them; (g) owner feedback 2026-10-17 01:23 +08: the period strip under the World State panel reads as tabs but is a read-only history ribbon (plain spans, hover shows the round headline; the panel always shows the latest committed round), and only the two arbiter-failed rounds (2027-02, 2027-03 · 1Δ, red border) stood out while the other seven ticks use the faint theme border — the API returns all nine rows. Resolved 2026-10-17 on `claude/wizardly-shamir-b82bf9` (see §9). + +- **i) Report stage after resume #1 — one chapter content-filtered on all six attempts (2026-10-17 02:41–03:02 +08, `report_62e8219d3990`).** Symptom: the resume regenerated the report (binary extraction recovered through the chunked path, 16 rows; no truncated cell), but the publish gate refused it with "报告仍有 1 个生成失败章节": "The Actor Architecture: Who Actually Decides the Outcome" stayed a placeholder after 3 + 3 (resurrection) attempts, every one ending in HTTP 400 code 1301. Root cause (read from `agent_log.jsonl`, not inferred): attempts 1–2 were rejected on the output side after a 48k-character `insight_forge` dump; attempt 3 and all three resurrection attempts produced a complete 13–15k-character `Final Answer` that *passed* the filter, which the ReAct loop rejected because the model had made 1 < min tool calls; the model then retrieved more (a 22k-character `panorama_search` dump) and the next request was rejected on the input side (`'role': 'user'`). The retries were identical replays — the LLM cache returned the same draft, and the resurrection `quick_search` carried its arguments flat (`{"name": …, "query": …}`) so the parser sent an empty query. Backoff cannot help a deterministic rejection. Fix (this session): `backend/app/services/report_filter_safe.py` + `report_agent.py` — (1) when a content filter interrupts an attempt that already holds a complete, uncontaminated draft rejected only for the tool-call floor, both generator paths (loop and forced-finish calls) return that draft and record `filter_safe_sections[title] = 0`; (2) otherwise `_generate_section_with_retry` climbs a no-backoff ladder: L1 = institutional-register directive + tool floor lowered to 1 + retrieved passages capped at 6k characters, L2 = no retrieval tools (dossier + prior chapters only, prior slices capped at 4k); (3) the native path no longer falls back to ReAct on a filter (same request → same rejection); (4) `_parse_tool_calls` accepts flat and OpenAI-style arguments; (5) `REPORT_FILTER_SAFE_LADDER=false` restores the old identical-retry behaviour; (6) `Report.filter_safe_sections` discloses the chapters written this way in `meta.json`. Rejected design, falsified live before shipping: rewriting leaders' names as office descriptors — probes against the same provider (scratchpad `probe_filter.py`, `probe_filter2.py`, 8 calls on the coding-plan endpoint) showed the original brief naming Trump and Xi passes, every directive wording passes, and the sanitized brief ("China's top leader", "China's lead trade negotiator") is itself filtered (V0/V3/V4 filtered, control/V1/V2/V5 passed). Guardrail: `tests/test_report_filter_safe.py` (18 tests: draft acceptance on both paths, never without a draft or for other errors, off switch, ladder order and recording, no backoff on filters, legacy backoff for transient errors, L1 directive/floor/cap, L2 tool-free, parser, disclosure). **Outcome (resume #2, `report_0f2e07d62a34`, 03:34–03:57 +08):** pipeline `completed`, publish gate `passed=True` with no hard or epistemic issues, `failed_sections=[]`, `partial=False`, structured forecast valid (3 scenarios, 17 binaries via the chunked extraction, confidence medium); `meta.json.filter_safe_sections` = {"Forecast Table and Calibration": 0, "The Actors: Trump, Xi, MOFCOM, BIS and the Concentration of Decision Rights": 1, "The Legal Cliff Calendar: November 2026 – January 2027 as the Decision Window": 2} — exactly the three rungs, each exercised once; those chapters are 9.4k/12.9k/11.9k characters with 35/41/46 `[S#]` markers and no leaked directive or tool markup. 21 code-1301 events in the run, none fatal. Only the optional Chinese translation was rejected by its own integrity audit (`full_report.zh.rejected.md`; 16 translation calls failed on provider errors, numeric multiset mismatch, 2 contamination lines) — follow-up in §9. + +## 7) Scenario-Focused Resolution Tests +- **A1 reflexivity:** repro = calendar run with the fake world LLM; round-1 decision "impose_measure" (tariff 10% → 25% via arbiter). Post-change: the round-2 WORLD CLOCK note of every active agent contains `# WORLD STATE — as of 2026-09-30 (after 2026-Q3)`, `- Tariff rate: 25% (was 10% last period)`, the move with its statement, the emergent event, the alliance shift and the pending commitment with its due period; the round-2 decision prompt shows the same facts plus `outcome so far: success` and `(DUE NOW)`. Verdict: resolved (`test_world_model_round_loop_is_reflexive`). +- **A2/A3 shared decisions + barrier:** two platforms with FakeEnvs; each (actor, round) decided exactly once; every `begin` of round r+1 on either platform comes after `commit r` in the recorded sequence; both platforms log the same world-decision posts and `world_update` rows. Verdict: resolved (`test_two_platforms_share_decisions_and_respect_barrier`). +- **A4 arbiter failure:** arbiter raises every round → `arbiter_status=failed` ×3, facts unchanged (10%), rounds still committed, moves/commitments/scheduled events still visible to agents. Verdict: resolved. +- **A5 invalid decisions:** unknown option id, garbage payload, custom action with a non-catalogued kind → typed `invalid` hold; provider error → `failed` hold. Verdict: resolved (`test_agent_decision.py`). +- **A6 herding guard:** public brief with scenario shares set contains no `%`, no share digits and no scenario names; private brief lists own commitments and moves aimed at the actor. Verdict: resolved (`test_world_model.py`). +- **A7 off switches:** `SIM_WORLD_MODEL=false` → legacy in-band elicitation runs, no world artifacts; hours mode with a `world_model` block → no coordinator, no world artifacts. Verdict: resolved. +- **A8 seed + seal:** legacy and canonical dossiers produce the block (variables with units/as-of/provenance, agency classes, allies/rivals, audience excluded, canonical actors read only validated pack rows); the sealed config carries the block and tampering breaks the seal. Verdict: resolved. +- **A9 downstream:** API containment/404/symlink/size/bad JSON; run_summary `world_model`; run-state projection keeps the latest round; report block labelled and bounded; chart indexes numeric histories and skips categorical variables; orchestrator summary. Verdict: resolved. +- **Resume:** completed world + checkpoint at round 1 → resume makes zero decision/arbiter calls, replayed rounds post organically and still receive the committed brief, `committed_round` stays 3. Verdict: resolved. +- **Live run (2026-10-16, owner-approved; `pipe_d569f211eb14` → `sim_d6ebcc77e22d`, glm-5.3, depth standard, max_rounds 12, English):** question "Forecast the state of US–China trade and technology restrictions as of 30 June 2027 …"; calendar unit month × 9 rounds (2026-10 … 2027-06), 7 agents (4 principals + 3 sampled). Checklist from the former §9: + - *arbiter applied in most rounds* — `world_state_history.jsonl`: 9 rows, `arbiter_status` applied ×7, failed ×2 (rounds 5–6, `llm error: BadRequestError` = provider content filter); `arbiter_problems` otherwise: r3 `unknown variable 'average_us_tariff_on_chinese_imports'`, r7 `unknown relationship direction 'flat'` (both rejected by validation, round still applied). Verdict: met. + - *option ids vary by agency class, no collapse to hold_position* — `world_actions.jsonl`: 50 decisions, all `status=ok`, `hold_position` 2/50 (4 %), 12 distinct non-hold ids. sovereign_government: respond_negotiate ×12, public_statement ×5, respond_accommodate ×3, impose_measure ×2, open_talks, respond_counter, form_alliance, hold ×2; individual_leader: respond_negotiate ×7, open_talks ×3, form_alliance ×3, set_agenda, respond_counter, signal_concession; legislature: introduce_bill ×4, respond_counter; firm: public_statement, respond_accommodate. Verdict: met. + - *round-2+ statements reference earlier consequences* — e.g. r2 MOFCOM "confirms it has received the US proposal for implementation-review talks" (Greer's r1 move); r3 BIS "following the November 9, 2026 expiration of the stay of the Affiliates Rule" (its own r2 measure); r4 MOFCOM counters the Affiliates Rule reinstatement with graphite licence review and Announcement No. 26 enforcement; r5 Greer "MOFCOM's January tightening of strategic-minerals enforcement runs contrary to…"; r6 Congress convenes a hearing on the industry lobbying Nvidia voiced in r5; r7 BIS "takes note of MOFCOM Announcement No. 14 of 2027" (r6); r8 Congress "having heard testimony last month"; r9 moves close the deal both leaders converged on in r7–r8. Verdict: met. + - *facts change with recorded causes inside the clamp* — 11 variable changes, 0 without a cause, 0 clamp violations; e.g. Average US tariff on imports from China 35.2 → 30.5 (r6, declared effect) → 25.0 (r9, effect) → 24.0 (r9, arbiter: "marginal headroom from deal-related … adjustments agreed at signature"); China average tariff on US exports 31.9 → 30.4 (r3); statutory average US tariff 11.0 → 10.6 → 10.3 (r3); Phase One agriculture fulfilment 77 → 78 → 82 %. Verdict: met. + - *each actor decided once per round across both platforms* — no duplicate (round, actor) in `world_actions.jsonl`; `twitter/actions.jsonl` and `reddit/actions.jsonl` each carry 48 `CREATE_POST` rows tagged `is_world_decision` (= the 48 non-hold decisions), identical text on both platforms, 9 `world_update` rows each with matching headlines. Verdict: met. + - *round wall time ≪ SIM_WORLD_BARRIER_TIMEOUT_S* — per-round wall time from the `world_update` timestamps: 42, 50, 83, 140, 70, 56, 39, 61, 86 s; max 140 s = 8 % of 1800 s. Verdict: met. + - *dashboard World State panel* — Simulation tab of the pipeline dashboard (built UI served by Flask on :5001): during the run "World state · 2027-01 · committed 4 · arbiter: applied · model projection, not evidence" with Key facts (32) showing deltas and provenance badges (e.g. "China average tariff on US exports 30.4 percent, 31.9 → 30.4, ARBITER"), Moves this period (4) with quoted statements, Consequences and events (9) tagged scheduled/consequence; after the run "2027-06 · committed 9 · arbiter: applied · 2 mechanical-only rounds". Verdict: met. + - *report diagnostic pack* — `charts/world_variables.html|png` generated and registered in `viz_manifest.json` (`id=world_variables`, title "Simulated World Variables (indexed, seed = 100)", `source=world_state`); `_world_evolution_block()` on the live `world_state.json` renders 2074 chars: 9 periods, 38 variables, 104 events, the "2 periods arbiter failed" warning, 6 variable trajectories, 8 decisive moves, alliance shifts; `run_summary.json.world_model` = {rounds_committed 9, variables 38, actors 7, relationships 11, events 104, pending_effects 6, arbiter_failed_rounds 2}. Verdict: met (the report stage itself failed its publish gate for reasons outside this branch, see §8). + - *realism review (feature step 7, reviewed by the agent; owner may re-review)* — MOFCOM acts through numbered announcements and licence-review enforcement; BIS reinstates and enforces the Affiliates Rule and answers Congress; Trump trades summits and tariff leverage for purchase numbers; Xi negotiates, prices concessions in soybean/coal volumes, pushes the deal; Greer insists on verification before concessions; Congress advances NDAA chip-control floors and holds hearings; Nvidia issues compliance statements and accommodates oversight. The world moved from a fragile truce (r1–r4) through two mechanical rounds to a signed agreement before the 30 June expiry (r9), with the tariff variable falling accordingly and scenario commitments shifting from "Fragile truce holds" to "Deal deepens". Verdict: actors react to world changes realistically → `passes: true`. + +- **Resume #2 of the live pipeline (content-filter handling):** repro = `POST /api/research/pipe_d569f211eb14/resume` on the fixed backend (pid 90076, 03:34 +08). Post-change behaviour (console_log): 03:37:32 "Forecast Table and Calibration" — filter after a floor-rejected draft → draft accepted (1 tool call); 03:38:30 "The Actors …" — filter → L1 → completed 03:40:04 (1 tool call); 03:39:37/03:40:04 "The Legal Cliff Calendar …" — filter at L0 and L1 → L2 (no tools) → completed 03:40:41; 03:46:30 final read-only audit `disk_match=True dangling=0`; 03:57:29 报告生成完成. Verdict: resolved — the run that had failed twice at the report stage is `completed` with a published report. + +## 8) Verification Summary +- **Fast checks run:** `tests/test_world_model.py` 18, `tests/test_agent_decision.py` 27, `tests/test_world_update.py` 6, `tests/test_world_model_seed.py` 9, `tests/test_world_model_downstream.py` 10 — all passed. `tests/test_world_model_loop.py` 6 + `tests/test_calendar_round_loop.py` 7 passed together; `tests/test_sim_reaction_phase.py` 20 passed after the runner patch. `tests/test_report_zh_presentation.py` 5 passed after the title entries. +- **Frontend:** `node --test tests/*.test.mjs` 145 passed (incl. the 5 new `worldState.test.mjs`); `npm run build` succeeded (rebuilt after the caption edit; result in §10). +- **CI gates:** `uvx ruff check --select F,E9 --ignore F401,F841,F541 backend/app backend/scripts deerflow_bridge` all checks passed; `check_env_drift.py --strict` every Config env var documented; `compileall` backend/app backend/scripts deerflow_bridge OK; `init.sh` smoke check passed. +- **Full backend suite:** first run (`-qq`, totals suppressed) 3 failed — `test_export_demo_site_data.py` ×2 (pre-existing, caused by the deleted `docs/demos` assets in this working tree, recorded in agent-progress before this session) and `test_report_zh_presentation.py::test_every_builder_title_has_a_chinese_title` (fixed: EN→ZH title entries). Final rerun: **2 failed (the same pre-existing demo-asset tests), 6229 passed, 22 skipped, 11 xfailed in 373.24 s**. +- **Live run (pipe_d569f211eb14, 2026-10-16T16:02:27Z → 17:21Z, status `failed` at the report stage):** stages research 15 min, ontology 1 min, graph 32 min, prepare 1 min, run 11 min (sim loop 631 s), report 18 min. Pipeline telemetry: 1,084 calls, 8.77 M input / 0.43 M output tokens, est. $6.20 (graph $3.10, report $1.55, research $0.75, run $0.71). `sim_llm_telemetry.json`: 218 calls, 908,115 prompt + 74,385 completion tokens, 631.5 s, 4 provider errors (the two arbiter rejections and their retries); `llm_health.json`: 0 platform-agent errors. World artifacts all present and rotated per run: `world_state.json` (38 variables, 7 actors, 11 relationships, 104 events, 6 pending effects), `world_state_history.jsonl` 9 rows, `world_actions.jsonl` 50 rows, `world_digest.jsonl` 9 rows, `world_state_trajectory.json` schema 3 with the `world_model` summary, `decisions.jsonl`, `run_summary.json.world_model`. Automated checks (`inspect_world_run.py`, kept in the session scratchpad; validated first on the offline two-platform test artifacts): no defects. +- **Report stage outcome (not a world-model defect):** all report artifacts were produced (12 sections, `full_report.md` 175 KB, `forecast.json` with 4 scenarios, 7 charts incl. `world_variables`), but the final read-only publish gate raised two hard issues: `structured_forecast.binary_count = 0` because the binary-forecast extraction call was rejected by the provider content filter (`console_log.txt` 01:19:41: "二元预测抽取失败 … Error code: 400 … code 1301"), and one table cell flagged as truncated (" general licences, tariff-cut lists) demonstrably incomplete"). Both are provider/report behaviours on this topic; the checkout's unrelated uncommitted edits to `forecast_extractor.py` / `report_lint.py` were loaded by the backend but do not explain either issue (the lint edit narrows the truncation detector; the cell is flagged with or without it). +- **Still not verified live:** the Reddit canonical attestation path (the live run used it with real agents and 0 errors, but no attestation rows were inspected); a run on a provider without content filtering; a multi-platform stall hitting the barrier timeout. + +- **Report-stage content-filter handling (2026-10-17):** `tests/test_report_filter_safe.py` 18 passed together with `tests/test_audit_fixes_report.py` (46 passed); the earlier regression subset (`test_audit_fixes_report`, `test_report_concurrent_sections`, `test_report_section_concurrency_parity`, `test_report_sections_partial`, `test_sessionb_downstream_gates`, `test_report_repair_and_reflection`, `test_forecast_binary_resilience`) 102 passed; full suite on the first version of the fix 2 failed (the same pre-existing demo-asset tests) / 6258 passed / 22 skipped / 11 xfailed in 317 s; ruff (F,E9), `check_env_drift.py --strict`, compileall OK. Live probes: 8 calls to glm-5.3 (coding plan), results in §6 i. Full suite on the final code: **2 failed (the same pre-existing demo-asset tests) / 6262 passed / 22 skipped / 11 xfailed in 320 s**. Live: resume #2 published `report_0f2e07d62a34` (22.5 min report stage; 10 chapters, 164.8 KB `full_report.md`, 8 charts incl. `world_variables`, `forecast.json` 3 scenarios / 17 binaries, `final_audit.json` gate passed, `meta.json.filter_safe_sections` 0/1/2 as in §6 i). + +## 9) Remaining Work & Next Steps +- **Commit** the branch once the suite rerun confirms only the two pre-existing demo-asset failures (code + tests + docs; exclude the unrelated pre-existing edits). +- **Live validation:** done (§7 "Live run"); `feature_reflexive_world_state_model.passes = true`. +- **Done 2026-10-17 on worktree branch `claude/friendly-murdock-e4a17e` (not pushed; see agent-progress.txt 02:45 +08):** precise `arbiter_problems` (class, HTTP status, provider code, content-filter class, snippet, failover outcome) and the same `problems` on decisions; `LLMClient.chat()` already retries once on `LLM_FALLBACK_PROVIDER` for every non-retryable error — the live run had none configured, so the records now say so (`fallback: not configured`); language check in decision validation (`status="language_mismatch"`, organic fallback); kind-name units blanked at validation, in the kernel and in every renderer. +- **Follow-ups from the live run (small, each worth a PR):** plot the seed value at the as-of date and the last value per period in `build_world_variables_html`; make the coordinator log through `main_logger` (or flush) so `simulation.log` carries the per-round lines; let the seed read the v3 dossier fields (`constraints`, `stated_vs_revealed`, `memory`) or extend the research contract with `future_plans` / `red_lines`; list valid variable ids and relationship directions explicitly in the arbiter prompt; make the panel's period strip a real round selector (show the selected round's headline, narrative, diff, moves and consequences from `world_state_history.jsonl`) and give applied ticks a visible style. +- **Follow-ups from the live run (small, each worth a PR):** record the provider error code/message in `arbiter_problems` and route the arbiter call through the fallback provider on content-filter 422 like `llm_client` does elsewhere; add a language check to decision validation (statement in the run language, else organic fallback); blank the unit of arbiter-created variables when it equals the kind name or the kind is non-numeric; plot the seed value at the as-of date and the last value per period in `build_world_variables_html`; make the coordinator log through `main_logger` (or flush) so `simulation.log` carries the per-round lines; let the seed read the v3 dossier fields (`constraints`, `stated_vs_revealed`, `memory`) or extend the research contract with `future_plans` / `red_lines`; list valid variable ids and relationship directions explicitly in the arbiter prompt; ~~make the panel's period strip a real round selector and give applied ticks a visible style~~ — done 2026-10-17 on `claude/wizardly-shamir-b82bf9` (side session): toggle-button ticks, past-period labelling with a one-click return, selection kept across live refreshes, per-row `arbiter_problems` / `error` / `digest` in the API; evidence in agent-progress.txt (entry 2026-10-17T02:35+08:00). +- **Risks:** (likelihood/impact) decision JSON quality on weaker providers — medium/medium, mitigated by typed holds and the organic fallback; arbiter over-moving variables — low/medium, mitigated by the change-ratio clamp and the validation problems log; barrier timeout on slow CLI providers — low/high, mitigated by the stall marking; prompt cost growth with many principals — medium/low, cap `SIM_WORLD_DECISION_MAX_ACTORS`. +- **Follow-ups worth their own session:** a world-state tab in the published report viewer (today only the diagnostic pack and chart), interview prompts that read the actor's own `world_actions.jsonl` history, and letting the arbiter resolve commitments explicitly instead of the "honoured by acting" rule. + +- **Resume #2 of `pipe_d569f211eb14`: done** (status `completed`, gate passed; §6 i / §7). The report-stage fixes (`forecast_binary_resilience.py`, `report_filter_safe.py`, the `report_lint.py` parenthetical refinement) are on the branch. +- **Follow-up (own session):** harden the bilingual translation pass against the provider filter — on this run 16 translation calls failed with provider errors and the Chinese version was rejected (`translation_status.zh.json`); candidates: route content-filtered chunks through the same no-backoff rewording ladder, retry when the provider is healthy, or publish the EN report with a clearly labelled partial translation. +- **Owner decision (2026-10-17 04:05 +08): no fallback provider for now.** `LLM_FALLBACK_PROVIDER` stays empty; the filter-safe ladder is judged enough for the moment. Revisit if filter-caused degradations recur (a fallback would remove the whole class at the cost of spend on another provider; the gateway on :8320 is down). +- **Translation follow-up started by the owner** as its own session ("Harden bilingual translation against provider content filter", 2026-10-17 04:05 +08); its result lands on a separate worktree branch. + +## 10) Updates to This File (append-only) +- 2026-10-16T13:50Z: created with request, constraints, plan and the survey findings. +- 2026-10-16T14:45Z: Steps 1–6 done with evidence; decisions changed during testing recorded (§5); issues/recoveries (§6); verification summary (§8); remaining work (§9). Full-suite rerun totals pending. +- 2026-10-16T14:50Z: final suite totals recorded (§8), Step 7 closed, status set to Complete (offline); live validation remains the only open item. +- 2026-10-16T16:05Z: owner approved the paid live run (their own words, 2026-10-17 00:01 +08); launched `pipe_d569f211eb14` (glm-5.3, depth standard, max_rounds 12, English, world-model flags at defaults) on the US–China trade question with horizon 2027-06-30; status line updated. Evidence will follow in §7/§8. +- 2026-10-16T17:50Z: added the owner's dashboard finding (§6 g, §9); note that this file is matched by the repo's `.gitignore` rule `handoff.md` (never committed; commit 4b2858f carried only design.md), so it stays local unless force-added. +- 2026-10-16T18:10Z: force-added to the branch at the owner's request (`git add -f`; the `.gitignore` rule is unchanged, so later edits need `-f` only once — the file is tracked from here on). Three follow-up sessions started from the §9 list (panel round selector; arbiter fallback/error persistence/language check; seed plans + chart seed point + coordinator logging), each on its own worktree branch. +- 2026-10-16T17:45Z: live run finished (sim 9/9 rounds, report publish gate failed for provider-filter reasons); evidence recorded in §7 "Live run" and §8; live findings (§5) and open issues (§6) added; §9 rewritten as follow-ups; ledger item closed; status set to Complete, feature flag set to true in feature_list.json. +- 2026-10-16T19:40Z: resume #1 of the live pipeline failed on one content-filtered chapter; root cause, live probes, the FILTER-SAFE fix and its tests recorded (§6 i, §8); §9 now tracks resume #2; header focus updated. +- 2026-10-16T20:05Z: resume #2 completed with a published report; outcome recorded (§6 i, §7, §8), §9 rewritten (translation follow-up, fallback-provider decision), header status/focus updated. +- 2026-10-16T20:10Z: owner decided against a fallback provider for now; translation follow-up session started by the owner (§9). +- 2026-10-16T18:50Z (2026-10-17 02:50 +08): follow-up session (worktree `claude/friendly-murdock-e4a17e`) closed the first three §9 live follow-ups: provider-failure records (`app/utils/llm_errors.py`, failover notes from `LLMClient._try_fallback`), the run-language script check (`language_mismatch`), and kind-name units; tests in test_world_update / test_agent_decision / test_world_model_loop / test_world_model / test_llm_errors; full backend suite: 6222 passed / 41 skipped / 11 xfailed / 0 failed; 19 setup errors only in test_legacy_official_data (needs the ignored `deer-flow/` checkout, absent in the worktree; 101/101 with it linked). Operational note: the failover has nowhere to go until `LLM_FALLBACK_PROVIDER` is set in the backend `.env`. +- 2026-10-17T18:35Z: side session `claude/wizardly-shamir-b82bf9` closed §6 (g) / the §9 panel follow-up (round selector, API per-row problems/error/digest); verification recorded in agent-progress.txt. diff --git a/docs/handoff/world-state-merge/handoff.md b/docs/handoff/world-state-merge/handoff.md new file mode 100644 index 00000000..84ab5c8a --- /dev/null +++ b/docs/handoff/world-state-merge/handoff.md @@ -0,0 +1,77 @@ +# Handoff.md — world-state merge (Codex engine × claude world model) and per-period simulation view + +**Last Updated (UTC):** 2026-10-17T11:45Z +**Status:** Complete — side branches merged, Codex mechanics ported, per-period view live-checked, merged to `main`. +**Current Focus:** none open; follow-ups in §9. + +Interpret MUST/SHOULD/MAY per RFC 2119. + +## 1) Request & Context +- **User's request (2026-10-17 19:34 +08, quoted):** "study the codex branch of the world state, and its logic and architecture and code, and merge it with your implementation of the world state model, taking the best parts of each and combining them, and then merge it to main. also, for the multi agent simulation tab, i can still not select the different time periods, only the 2027-06-H2 · 4Δ tab, and not the previous periods. i want to be able to see the world states for the prior periods, and the agent feeds during those periods. that is to say, for each period tab, i want to see the world state for that period, and the agent feed for that period, instead of showing the full feed in every period tab." +- **Two implementations in play:** + - `claude-reflexive-world-state-model` (this checkout, PR linroger/DeepResearchForecast#6): reflexive world model *inside* the OASIS calendar loop — `world_model.py` kernel, `agent_decision.py` option menus, `world_update.py` deterministic step + bounded arbiter, `_ReflexiveWorldCoordinator` barrier, artifacts, `/world-state` API, `WorldStatePanel.vue`, report block + chart. Live-validated on `pipe_d569f211eb14`. Ledger: `docs/handoff/reflexive-world-state/handoff.md`. + - `codex/reflexive-world-simulation` (worktree `~/.codex/worktrees/reflexive-world-simulation/DeepResearchForecast`, 6 commits on main 8852ce1): a separate opt-in engine `world-v1` (`backend/app/services/world_simulation/`: compiler → immutable `world_spec.json`, transition kernel with typed mechanisms and consent, round controller with private observations and offer/response phase, SQLite fenced store, strict budgeted HTTP policy, results projections, `GET /api/simulation//world`, `WorldSimulationView.vue`, conditional report appendix, behaviour evaluation harness). Its own delivery record keeps `oasis-legacy` as the default because the live behaviour gate was not met. +- **Unmerged side branches of the claude implementation (from the live-validation follow-ups):** `refs/heads/claude/wizardly-shamir-b82bf9` (03dcd94, World State period strip as a round selector), `refs/heads/claude/friendly-murdock-e4a17e` (5797ae0, provider-failure records, run-language check, unit hygiene), `refs/heads/claude/quirky-lamport-99f71d` (0e57c77, seed plans from v3 dossiers, chart seed point, intact simulation.log). `refs/heads/claude/gifted-elion-7c02f2` (0329979, bilingual translation filter-safe) is report-side and separate. +- **Constraints:** offline tests first (FakeLLMClient / FakeEnv harnesses); hours mode and non-world runs byte-identical; the user wants the combined result on `main`; the pre-existing unrelated uncommitted edits in this checkout stay uncommitted. +- **Scope boundaries:** the Codex engine is not merged wholesale (its own evaluation keeps it opt-in and it replaces the social simulation); parts are ported into the integrated model. No new paid live run unless the owner asks; the dashboard check uses existing run artifacts. + +## 2) Requirements → Acceptance Checks (traceable) +| Requirement | Acceptance Check (scenario steps) | Expected Outcome | Evidence to Capture | +|---|---|---|---| +| R1 Side branches merged | merge the three branches, run their tests + world suites | all green, no behaviour regressions | test totals in §8 | +| R2 Codex study | architecture/code summary + decision table of what is ported and what is not, with reasons | written in §5 | this file | +| R3 Ported parts work | new offline tests for each ported mechanism (consent/offers, modelling gaps, reflexivity metric) | tests green; artifacts carry the new fields | test names in §8 | +| R4 Per-period view | open the Simulation tab of a completed world run; click an earlier period | panel shows that period's world state (facts, moves, events) and the feed shows only that period's posts | dashboard check (headless browser) in §7 | +| R5 Merge to main | PR #6 merged (merge commit) and `main` pushed | `git log main` contains the branch; remote updated | §8 | + +## 3) Plan & Decomposition (with rationale) +1. Survey (done first because it fixes the scope): branch relations, Codex docs (ARCHITECTURE/DELIVERY/handoff/contracts), code summary by a read-only agent. +2. Merge the side branches oldest-first (`friendly-murdock` → `quirky-lamport` → `wizardly-shamir`), resolving conflicts against the report-stage commits; run the world/report suites after each. +3. Port from Codex (decided in §5): offer/consent interactions with obligations and breach; seed modelling-gap reporting; reflexivity/feedback metrics; per-actor permitted facts only if cheap. +4. Per-period view: period selector drives both the world-state panel and the feed (posts filtered by the round/period they were posted in; backend exposes the round → period mapping and per-round post ranges). +5. Suites, build, headless dashboard check, merge to main, push, record. + +## 4) To-Do & Progress Ledger +- [x] Survey branches, worktrees, PR state — done (§5 findings). +- [x] Codex code summary (read-only agent) and docs read — done (§5). +- [x] Merge side branches — done: cb94a2f (friendly-murdock: 159 tests), 4c6a7e2 (quirky-lamport: 165 tests), 5f9f54a (wizardly-shamir: frontend 156/156, downstream 23). +- [x] Port Codex parts — done (offline): consent rule (`world_model.Interaction`, menu answers, decision `terms`, obligations, breach; `tests/test_world_interactions.py` 7), seed modelling gaps (`world_model_seed.seed_gaps`), behaviour diagnostics (`world_update.behaviour_metrics`), exposure in run summary / `/world-state` API / panel card and badges / report block; design.md §2.10 records what was ported and what was not. +- [x] Per-period panel + feed — done: `backend/app/services/feed_rounds.py` (round index from `/actions.jsonl` + the platform SQLite), `GET //feed-rounds`, `round=` on `/posts` and `/comments` (6 tests), `frontend/src/utils/feedRounds.js` (4 tests), panel `round` prop + `update:selectedRound` emit (panel test), `SimulationView` feed follows the selection; live check in §7. +- [x] Suites/build/dashboard check — see §8. +- [x] Merge to main, push, record — see §8/§10. + +## 5) Findings, Decisions, Assumptions +- **Finding:** `main` (8852ce1) has nothing the branch lacks; the branch is 10 commits ahead; PR #6 is MERGEABLE (UNSTABLE = checks pending/none). +- **Finding:** the Codex branch is 198 files / +34k lines, of which ~19k are code and tests under `backend/app/services/world_simulation/` plus heavy process evidence; its live study scored investment 87.5 %, trade 49 %, coalition 50 % and its delivery keeps the engine opt-in. +- **Decision (after the code summary):** keep the integrated claude model as the base and port mechanisms, not the engine. The read-only code study found world-v1 to be a deterministic typed-ledger engine (8 pre-declared action kinds over accounts/metrics/rules/relations, no arbiter, no social feed, seeds without effect, output never feeding the forecast) that runs *instead of* OASIS, with its compiler/policy/service coupled to repo internals and its tests gated behind a guarded runner; its own study kept it opt-in. Ported: the consent rule (offers → answers → obligations → breach), modelling gaps instead of invention, and a textual responsiveness diagnostic in the spirit of its behaviour evaluation. Not ported: SQLite fenced store, exact-decimal replay, strict budgeted HTTP policy, the mechanism compiler, the preregistered evaluation harness (reasons in design.md §2.10). +- **Finding (feed index):** OASIS stores posts with the sandbox clock only; `/actions.jsonl` is the only round-stamped record, and the live US–China run's resurrection `quick_search` showed flat tool arguments — both handled (index by agent + content consumed in log order; parser accepts flat args). + +## 6) Issues, Mistakes, Recoveries +- **Concurrent edits in this checkout (2026-10-17 ~20:07–20:14 +08):** while this session worked, another process modified `backend/app/services/report_visualizer.py` (removing the `market_price_history` chart family with a dated note), `report_agent.py` (removing `_locate_handoff_dir`), `pipeline_orchestrator.py`, `deerflow_bridge/deerflow_research.py` and five test files. Symptom: the shared-tree full suite failed in official-data / visualizer tests; root cause: those in-progress foreign edits; fix: my hunks were staged selectively (a filtered patch for `report_agent.py`) and verified in an isolated worktree; guardrail: the commit contains only files/hunks of this effort, the foreign changes stay uncommitted for their owner. +- **zsh word splitting:** `git diff HEAD -- $LIST` with an unquoted scalar does not split in zsh, so the first isolated run carried one file only; re-run with `${=LIST}`. +- **Merge regression:** taking the quirky-lamport side of the chart-builder conflict dropped Copilot's zero-seed skip; caught by the isolated full suite and restored. + +## 7) Scenario-Focused Resolution Tests +- **Per-period view (R4):** repro = dev UI on :3000 → History → the Brent crude run (sim_dc80b124fb89, 17 committed half-month rounds) → Simulation tab. Before: the period chips were not selectable and the feed always showed the newest 60 posts. After: clicking `2026-11-H1` shows "past period · round 2 / 17" in the World State panel with that period's facts/moves/events, the feed header reads "FEED (7) · 2026-11-H1" with an "↩ all periods" button, the requests carry `round=2` (`/posts?…&limit=500&round=2`, `/comments?…&limit=1000&round=2`), and "all periods" returns to "Feed (60)" with the panel back on the latest round. On the US–China run (sim_d6ebcc77e22d) the index maps all 60 posts / 47 replies (0 unmatched) to rounds 0–9 labelled 2026-10 … 2027-06. Verdict: resolved. + +## 7b) Scenario-focused tests of the ported mechanics (offline) +- **Consent rule:** a `form_alliance` move aimed at Agency opens `[i1_1]` with terms; Agency's next menu starts with `accept:/counter:/reject:/defer:i1_1` and carries no duplicated generic response; `accept` binds both parties (commitments `agreement:i1_1`, counterparty, due next period) and moves the relationship closer; acting when due fulfils them without breach; `reject` pushes the relationship below its post-offer value; `defer` keeps the offer open one more period; `counter` opens `[i4_3]` back to the proposer; an answer to a settled offer is recorded, not applied; an unanswered offer lapses at round 4; a lapsed obligation flips the agreement to `breached`, costs reputation and marks the obligation `breached`; `break_alliance` breaches an agreement in force (`released` on the other side). Verdict: resolved (`tests/test_world_interactions.py`). +- **Gaps and behaviour:** a seed with a unit-less fact, an unclassified actor without goals or plans and no relationships reports six typed gaps; a garbage seed reports four and says so in its summary line; the kernel keeps them across save/load. A round-2 statement naming a fact that changed in round 1 and an offer answer count as responsive (2/3 moves, share 0.667); round 1 scores nothing. Verdict: resolved (`test_world_model_seed.py`, `test_world_update.py`). + +## 8) Verification Summary +- **Codex-port batch (offline):** world suites after the port 170 passed (`test_world_interactions.py` 7, downstream 14, update, seed, loop, decision, calendar, reaction); frontend `node --test` 163 passed; `npm run build` OK; ruff (F,E9), `check_env_drift.py --strict`, compileall OK. +- **Full backend suite on exactly my hunks, in an isolated worktree** (`git worktree add` at HEAD + the staged patch, because a concurrent process is editing this checkout — see §6): **6287 passed, 1 failed, 19 errors, 33 skipped, 11 xfailed in 420 s**. The failure (`test_world_variables_html_skips_zero_seed…`) was a real regression from the quirky-lamport merge (its chart helper plotted un-indexable zero-seed variables) and is fixed in the same commit (zero seeds are skipped again); the 19 errors are `test_legacy_official_data.py` fixtures that need the untracked `deer-flow/` tree, absent from the worktree (they pass in the main checkout). The two `test_export_demo_site_data` failures of earlier runs come from deleted demo assets in this working tree only. +- **Shared working tree:** the full suite there shows 16 failures / 198 errors in official-data and visualizer tests that belong to the concurrent edits (§6), not to this branch. +- Backend: `tests/test_feed_rounds.py` 6 + `tests/test_world_model_downstream.py` 13 → 19 passed; world suites after the merges 165 passed. Frontend: `node --test tests/*.test.mjs` 161 passed (new `feedRounds.test.mjs` 4, panel emit test); `npm run build` OK after aliasing a name clash (`roundLabel`). + +## 9) Remaining Work & Next Steps +- A paid live run is the only way to see offers/consent, modelling gaps and the behaviour diagnostics on real artifacts (existing runs predate them); the panel, report block and API degrade cleanly without them. +- Per-actor fact visibility (private facts) was considered from the Codex design and not ported in this round. +- The concurrent process's removal of the market price-history charts is its own work; whoever owns it should finish the tests it touched before the next full-suite claim on this checkout. + +## 10) Updates to This File (append-only) +- 2026-10-17T11:45Z: created with the request, both implementations, side branches, plan and acceptance table. +- 2026-10-17T12:00Z: side branches merged; Codex summary and port decision recorded; per-period feed delivered and live-checked (§4, §7, §8). +- 2026-10-17T12:15Z: Codex port landed offline (consent rule, gaps, behaviour diagnostics) with design.md §2.10 and README (EN/ZH, line-aligned) updates; suite totals to follow. +- 2026-10-17T12:30Z: isolated full-suite totals recorded, zero-seed regression fixed, concurrent-edit incident recorded (§6); status Complete pending the merge to main (§8/§10 updated after the merge). +- 2026-10-17T12:35Z: Codex-port batch committed as 1de81a5; branch pushed and `main` fast-forwarded to the branch tip (PR linroger/DeepResearchForecast#6 thereby merged). diff --git a/feature_list.json b/feature_list.json index d2a4b91b..647564a7 100644 --- a/feature_list.json +++ b/feature_list.json @@ -222,5 +222,21 @@ ], "passes": false, "note": "Steps 1-5 verified offline 2026-10-16: tests/test_research_skills.py (133 cases incl. 40+ real EN/ZH routing cases and false-positive guards), the scripted full-run test test_e2e_method_skills_reach_every_phase_and_fill_the_forecast_frame (skills on and off), the guarded-runner end-to-end and the full backend suite. Step 6 needs a paid live run, which needs owner approval. Design and review records: docs/handoff/research-skills/ (design.md, review-results.md)." + }, + { + "id": "feature_reflexive_world_state_model", + "category": "simulation", + "description": "The multi-agent simulation evolves an explicit world state every calendar round: actors observe the committed world, make one structured, motivated decision from a realistic option set (goals, incentives, alliances, constraints, capacity), publish it, and a single world update (deterministic kernel + bounded arbiter) changes facts, relationships and pending consequences that every agent observes next round.", + "steps": [ + "Start a calendar-mode run; PREPARE seals a world_model block (state variables from quantitative facts and official data, actor agency classes, valenced relationships) into simulation_config.json.", + "In round 1 every deciding actor receives the WORLD STATE brief plus a grounded option menu and returns a structured decision; its public statement appears as its post on both platforms (one decision per actor per round).", + "After both platforms deliver the round, exactly one world update commits: facts change with recorded causes, commitments register, relationships shift, scheduled events fire; world_state.json and world_state_history.jsonl are written and a world_update row lands in actions.jsonl.", + "In round 2 the WORLD CLOCK note and every decision prompt show the round-1 consequences (changed facts with their previous values, moves, events, alliance shifts, pending commitments) and no scenario probabilities.", + "A failed arbiter call still commits the round mechanically with arbiter_status=failed; SIM_WORLD_MODEL=false and hours mode produce no world artifacts and run the legacy path unchanged.", + "The dashboard simulation tab shows the World State panel, run_summary.json carries a world_model block, and the report's diagnostic pack renders the world evolution and a world-variables chart.", + "A live run (real LLM) shows actors reacting to world changes across rounds; a human review confirms the moves are realistic for each actor's agency class." + ], + "passes": true, + "note": "Offline: tests/test_world_model*.py, test_agent_decision.py, test_world_update.py, test_world_model_loop.py (2026-10-16). Live (owner-approved, 2026-10-16/17): pipe_d569f211eb14 / sim_d6ebcc77e22d on glm-5.3, 9 monthly rounds, 7 agents: arbiter applied 7/9 (2 provider content-filter rejections committed mechanically), 50 valid decisions with 12 distinct option ids by agency class, one decision per actor per round on both platforms, 11 caused fact changes inside the clamp, rounds 39-140 s vs the 1800 s barrier, dashboard panel + run_summary + report chart/block verified. Evidence: docs/handoff/reflexive-world-state/handoff.md section 7 'Live run'. Follow-ups listed there (section 9)." } ] diff --git a/frontend/src/api/simulation.js b/frontend/src/api/simulation.js index 26a21f76..d308a005 100644 --- a/frontend/src/api/simulation.js +++ b/frontend/src/api/simulation.js @@ -118,10 +118,11 @@ export const getRunStatusDetail = (simulationId) => { * @param {number} limit - 返回数量 * @param {number} offset - 偏移量 */ -export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => { - return service.get(`/api/simulation/${simulationId}/posts`, { - params: { platform, limit, offset } - }) +export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0, round = null) => { + // PERIOD VIEW: `round` (a committed round number) narrows the feed to that period's posts + const params = { platform, limit, offset } + if (round !== null && round !== undefined) params.round = round + return service.get(`/api/simulation/${simulationId}/posts`, { params }) } /** @@ -131,10 +132,19 @@ export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50 * @param {number} limit - 返回数量 * @param {number} offset - 偏移量 */ -export const getSimulationComments = (simulationId, platform = 'reddit', limit = 200, offset = 0) => { - return service.get(`/api/simulation/${simulationId}/comments`, { - params: { platform, limit, offset } - }) +export const getSimulationComments = (simulationId, platform = 'reddit', limit = 200, offset = 0, round = null) => { + // PERIOD VIEW: `round` narrows the replies to the ones written in that period + const params = { platform, limit, offset } + if (round !== null && round !== undefined) params.round = round + return service.get(`/api/simulation/${simulationId}/comments`, { params }) +} + +/** + * PERIOD VIEW: the rounds of a platform's feed with period labels and post/reply counts + * (GET /api/simulation//feed-rounds). Empty `rounds` before the run has an action log. + */ +export const getFeedRounds = (simulationId, platform = 'reddit') => { + return service.get(`/api/simulation/${simulationId}/feed-rounds`, { params: { platform } }) } /** @@ -143,6 +153,14 @@ export const getSimulationComments = (simulationId, platform = 'reddit', limit = * @param {number} startRound - 起始轮次 * @param {number} endRound - 结束轮次 */ +/** + * Reflexive world model: latest committed world state + bounded per-round history. + * 404 while the world has not committed a round yet (hours mode / legacy runs). + */ +export const getWorldState = (simulationId, rows = 24) => { + return service.get(`/api/simulation/${simulationId}/world-state`, { params: { rows } }) +} + export const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => { const params = { start_round: startRound } if (endRound !== null) { diff --git a/frontend/src/components/research/SimulationView.vue b/frontend/src/components/research/SimulationView.vue index 85583df6..448cb081 100644 --- a/frontend/src/components/research/SimulationView.vue +++ b/frontend/src/components/research/SimulationView.vue @@ -70,6 +70,14 @@ + + +

@@ -115,6 +123,12 @@
◇ {{ L('信息流','Feed') }} ({{ posts.length }}) + + · {{ selectedPeriodLabel }} + +
@@ -132,6 +146,7 @@ {{ platformTag }} · {{ relativeTime(post) }} + · {{ periodNote(post) }}
@@ -170,6 +185,11 @@ +
+ ◇ + {{ L('本时段无帖子','No posts in this period') }} · {{ selectedPeriodLabel }} +
+
◇ {{ L('模拟尚未运行或暂无数据','Simulation not started or no data yet') }} @@ -187,13 +207,16 @@ + + diff --git a/frontend/src/utils/feedRounds.js b/frontend/src/utils/feedRounds.js new file mode 100644 index 00000000..1b8721e9 --- /dev/null +++ b/frontend/src/utils/feedRounds.js @@ -0,0 +1,46 @@ +/** + * Period view of the simulation feed: pure helpers shared by SimulationView and its tests. + * + * `rounds` are the rows of GET /api/simulation//feed-rounds + * ({ round, period_label, headline, posts, comments }); `selectedRound` is null while the + * feed follows the latest posts and a round number while one committed period is pinned. + */ + +/** The label of a round: its period label, the given fallback, or `#`. */ +export function roundLabel(rounds, round, fallback = '') { + const n = Number(round) + if (!Number.isFinite(n)) return fallback || '' + const row = (Array.isArray(rounds) ? rounds : []).find((r) => r && Number(r.round) === n) + if (row && row.period_label) return String(row.period_label) + return fallback || `#${n}` +} + +/** Normalise what the World State panel emits (number, numeric string, null, undefined). */ +export function selectionFromPanel(value) { + if (value === null || value === undefined || value === '') return null + const n = Number(value) + return Number.isFinite(n) ? n : null +} + +/** + * The posts/comments request for the current selection: the newest window when no period + * is pinned, a whole period (server-side `round` filter, generous caps) when one is. + */ +export function feedRequest(selectedRound) { + if (selectedRound === null || selectedRound === undefined) { + return { round: null, limit: 60, commentLimit: 200 } + } + return { round: Number(selectedRound), limit: 500, commentLimit: 1000 } +} + +/** + * The note shown on a post card inside a pinned period when the post itself was written + * in an earlier period (it appears because it was replied to this period): "posted in + *