From d056d386fc8cf061a8029f726f02219b8f51a63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BC=A0=E6=AC=A3?= Date: Thu, 13 Aug 2026 01:32:27 +0800 Subject: [PATCH 1/3] feat: add auditable demo and joint investment funds --- pyproject.toml | 2 +- src/insidegov/__init__.py | 2 +- src/insidegov/api.py | 79 ++++++++++++++++- src/insidegov/cases.py | 115 +++++++++++++++++++++++-- src/insidegov/cli.py | 34 +++++++- src/insidegov/demo.py | 134 +++++++++++++++++++++++++++++ src/insidegov/engine.py | 115 ++++++++++++++++++++++++- src/insidegov/models.py | 23 +++++ src/insidegov/reporting.py | 159 +++++++++++++++++++++++++++++++++++ src/insidegov/serde.py | 5 ++ tests/test_demo_workbench.py | 72 ++++++++++++++++ tests/test_real_case.py | 34 +++++++- uv.lock | 2 +- 13 files changed, 762 insertions(+), 14 deletions(-) create mode 100644 src/insidegov/demo.py create mode 100644 src/insidegov/reporting.py create mode 100644 tests/test_demo_workbench.py diff --git a/pyproject.toml b/pyproject.toml index e535693..e65a7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "insidegov" -version = "0.4.0" +version = "0.5.0" description = "A reproducible multi-agent policy laboratory for government-business interaction" readme = "README.md" requires-python = ">=3.11" diff --git a/src/insidegov/__init__.py b/src/insidegov/__init__.py index 2d55e1f..45574b3 100644 --- a/src/insidegov/__init__.py +++ b/src/insidegov/__init__.py @@ -15,4 +15,4 @@ "create_negotiation_world", "create_talent_world", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/insidegov/api.py b/src/insidegov/api.py index 4b7c9ff..728d9f4 100644 --- a/src/insidegov/api.py +++ b/src/insidegov/api.py @@ -12,6 +12,8 @@ from fastapi.responses import Response from pydantic import BaseModel, Field +from .cases import run_hefei_nio_sensitivity +from .demo import create_hefei_nio_demo from .engine import SimulationEngine from .experiments import ( run_comparison, @@ -30,6 +32,7 @@ parse_intervention, render_world_report, ) +from .reporting import ExperimentReportRepository from .repository import WorldRepository from .scenarios import ( create_full_lifecycle_world, @@ -146,9 +149,14 @@ class NegotiationMatrixRequest(BaseModel): include_llm: bool = False +class DemoRequest(BaseModel): + seed: int = Field(default=42, ge=0) + fiscal_multiplier: float = Field(default=0.5, ge=0.1, le=1.0) + + app = FastAPI( title="InsideGov API", - version="0.3.0", + version="0.5.0", description="Reproducible government-business interaction policy laboratory", ) app.add_middleware( @@ -163,6 +171,7 @@ class NegotiationMatrixRequest(BaseModel): worlds: dict[str, SimulationEngine | NegotiationEngine | TalentSimulationEngine] = {} repository = WorldRepository() candidate_repository = CandidateRepository() +experiment_report_repository = ExperimentReportRepository() def _public_world(world) -> dict: @@ -180,7 +189,7 @@ def _public_world(world) -> dict: @app.get("/health") def health() -> dict[str, str]: - return {"status": "ok", "version": "0.3.0"} + return {"status": "ok", "version": "0.5.0"} @app.get("/capabilities") @@ -197,7 +206,73 @@ def capabilities() -> dict: "grounded_interviews": True, "automatic_reports": True, "material_candidate_worlds": True, + "guided_demo": True, + "experiment_report_center": True, + "joint_investment_funds": True, + } + + +@app.post("/demos/hefei-nio") +def hefei_nio_demo(request: DemoRequest) -> dict: + bundle = create_hefei_nio_demo( + repository, + seed=request.seed, + fiscal_multiplier=request.fiscal_multiplier, + ) + bundle["baseline"] = _public_world(bundle["baseline"]) + bundle["branch"] = _public_world(bundle["branch"]) + return bundle + + +@app.get("/cases/hefei-nio/sensitivity") +def hefei_nio_sensitivity(seed: int = 42, quarters: int = 16) -> dict: + return run_hefei_nio_sensitivity(seed=seed, quarters=quarters) + + +@app.get("/experiment-reports") +def experiment_reports() -> list[dict]: + return experiment_report_repository.list() + + +@app.get("/experiment-reports/{report_id}/download") +def download_experiment_report(report_id: str) -> Response: + report = experiment_report_repository.load(report_id) + if report is None: + raise HTTPException(404, "experiment report not found") + return Response( + json.dumps(report, ensure_ascii=False, indent=2), + media_type="application/json", + headers={ + "Content-Disposition": f'attachment; filename="{report_id}.json"', + }, + ) + + +@app.get("/experiment-reports/{report_id}/worlds/{world_id}") +def experiment_report_world(report_id: str, world_id: str) -> dict: + report = experiment_report_repository.load(report_id) + if report is None: + raise HTTPException(404, "experiment report not found") + referenced_worlds = { + item.get("world_id") + for group in ("strategy_runs", "ablation_runs") + for item in report.get(group, []) } + if world_id not in referenced_worlds: + raise HTTPException(404, "world is not referenced by this report") + world = experiment_report_repository.load_world(world_id) + if world is None: + raise HTTPException(404, "archived matrix world not found") + worlds[world.id] = SimulationEngine(world) + return _public_world(world) + + +@app.get("/experiment-reports/{report_id}") +def experiment_report(report_id: str) -> dict: + report = experiment_report_repository.load(report_id) + if report is None: + raise HTTPException(404, "experiment report not found") + return report @app.get("/scenarios") diff --git a/src/insidegov/cases.py b/src/insidegov/cases.py index 23378bb..8e47264 100644 --- a/src/insidegov/cases.py +++ b/src/insidegov/cases.py @@ -2,7 +2,8 @@ from __future__ import annotations -from .models import Event +from .engine import SimulationEngine +from .models import Event, InvestmentFundState from .scenarios import create_full_lifecycle_world NIO_AGREEMENT_URL = ( @@ -41,7 +42,12 @@ def _source( } -def create_hefei_nio_world(seed: int = 42, world_id: str = "hefei-nio-2020"): +def create_hefei_nio_world( + seed: int = 42, + world_id: str = "hefei-nio-2020", + available_budget_share: float = 0.25, + include_joint_investment: bool = True, +): """Calibrate the existing lifecycle model to the 2020 Hefei–NIO case. Public facts are retained separately from expert/model mappings. The simulator @@ -53,7 +59,9 @@ def create_hefei_nio_world(seed: int = 42, world_id: str = "hefei-nio-2020"): hefei = world.cities["city_lin"] hefei.name = "合肥市" hefei.fiscal_budget = 762.9 - hefei.available_budget = 190.725 + if not 0.05 <= available_budget_share <= 0.60: + raise ValueError("available_budget_share must be between 0.05 and 0.60") + hefei.available_budget = round(hefei.fiscal_budget * available_budget_share, 3) hefei.committed_expenditure = 20.0 hefei.debt = 500.0 hefei.industrial_land = 650.0 @@ -87,17 +95,49 @@ def create_hefei_nio_world(seed: int = 42, world_id: str = "hefei-nio-2020"): world.agents["city_lin_investment"].private_facts.update({ "competitive_intensity": 0.88, "cash_preference": 0.28, + "external_fund_target": 53.52 if include_joint_investment else 0.0, }) + if include_joint_investment: + world.investment_funds = { + "fund_hefei_platforms": InvestmentFundState( + id="fund_hefei_platforms", + name="合肥市建设与产业投资平台联合体", + city_id="city_lin", + source_level="municipal_state_capital", + available_capital=20.0, + risk_tolerance=0.92, + due_diligence_threshold=0.64, + ), + "fund_cmg_sdic": InvestmentFundState( + id="fund_cmg_sdic", + name="国投招商投资管理联合体", + city_id="city_lin", + source_level="national_industrial_fund", + available_capital=18.0, + risk_tolerance=0.88, + due_diligence_threshold=0.68, + ), + "fund_anhui_emerging": InvestmentFundState( + id="fund_anhui_emerging", + name="安徽省高新技术产业投资平台", + city_id="city_lin", + source_level="provincial_guidance_fund", + available_capital=16.0, + risk_tolerance=0.86, + due_diligence_threshold=0.66, + ), + } + world.parameter_provenance = { "city_lin.fiscal_budget": _source( "2020年合肥一般公共预算收入762.90亿元;它是财政规模口径,不等于项目可动用资金。", 762.9, "public_source", HEFEI_STATISTICS_URL, 0.90, ), "city_lin.available_budget": _source( - "以一般公共预算收入的25%作为演示世界可调度财力;公开资料没有给出项目专属可用余额。", - 190.725, "expert_judgment", HEFEI_STATISTICS_URL, 0.45, - "762.9×25%;必须做15%—35%敏感性分析。", + f"以一般公共预算收入的{available_budget_share:.0%}作为演示世界可调度财力;公开资料没有给出项目专属可用余额。", + hefei.available_budget, "expert_judgment", HEFEI_STATISTICS_URL, 0.45, + "默认762.9×25%;发布报告同时给出15%—35%敏感性分析。", ), "city_lin.objective_credibility": _source( "2020年6月首两期50亿元中已到账48亿元;2020年年报确认双方现金出资义务全部履行。", @@ -141,6 +181,14 @@ def create_hefei_nio_world(seed: int = 42, world_id: str = "hefei-nio-2020"): 70.0, "public_source", NIO_AGREEMENT_URL, 0.99, "用于历史对照,不直接写入Agent提案或规则结算。", ), + "joint_investment.available_capital": _source( + "公开协议列明合肥市建设投资控股、合肥市产业投资控股、国投招商及安徽省高新技术产业投资等战略投资方。", + 54.0 if include_joint_investment else 0.0, + "expert_judgment", + NIO_AGREEMENT_URL, + 0.72, + "模型把70亿元历史总股权投资拆为本级财政股权工具与独立产业基金共同出资;各基金份额是可审计建模假设。", + ), "historical_contract.payment_schedule": { "evidence": "战略投资者五期投入:35、15、10、5、5亿元;截止日依次为交割后5个工作日、2020-06-30、2020-09-30、2020-12-31、2021-03-31。", "final_value": [35.0, 15.0, 10.0, 5.0, 5.0], @@ -160,3 +208,58 @@ def create_hefei_nio_world(seed: int = 42, world_id: str = "hefei-nio-2020"): severity="success", )) return world + + +def run_hefei_nio_sensitivity( + seed: int = 42, + budget_shares: tuple[float, ...] = (0.15, 0.25, 0.35), + quarters: int = 16, +) -> dict: + """Compare fiscal-space mappings with and without separately governed funds.""" + + runs = [] + for include_joint_investment in (False, True): + for share in budget_shares: + world = create_hefei_nio_world( + seed=seed, + world_id=( + f"hefei-nio-sensitivity-{int(share * 100)}-" + f"{'joint' if include_joint_investment else 'municipal'}" + ), + available_budget_share=share, + include_joint_investment=include_joint_investment, + ) + result = SimulationEngine(world).run(quarters) + offer = result.cities["city_lin"].active_offer + final = result.history[-1] + municipal_equity = offer.equity if offer else 0.0 + external_equity = offer.external_equity if offer else 0.0 + runs.append({ + "available_budget_share": share, + "joint_investment": include_joint_investment, + "selected_city": result.selected_city_id, + "municipal_equity": round(municipal_equity, 3), + "external_equity": round(external_equity, 3), + "total_equity_support": round(municipal_equity + external_equity, 3), + "historical_equity_gap": round( + 70.0 - municipal_equity - external_equity, 3 + ), + "fulfilled_promises": sum( + item.status.value == "fulfilled" for item in result.promises + ), + "cluster_size": final.cluster_size, + "total_employment": final.total_employment, + "average_credibility": final.average_credibility, + }) + return { + "case": "2020合肥—蔚来", + "seed": seed, + "quarters": quarters, + "historical_equity_investment": 70.0, + "budget_shares": list(budget_shares), + "runs": runs, + "interpretation_boundary": ( + "联合投资机制用于解释多层级资本如何改变可执行政策包;" + "敏感性结果是模型内比较,不是现实政策效应估计。" + ), + } diff --git a/src/insidegov/cli.py b/src/insidegov/cli.py index d098173..6c0ad4f 100644 --- a/src/insidegov/cli.py +++ b/src/insidegov/cli.py @@ -5,7 +5,8 @@ import sys from dataclasses import asdict -from .cases import create_hefei_nio_world +from .cases import create_hefei_nio_world, run_hefei_nio_sensitivity +from .demo import create_hefei_nio_demo from .engine import SimulationEngine from .experiments import ( run_comparison, @@ -16,6 +17,7 @@ run_talent_matrix, ) from .negotiation_engine import NegotiationEngine +from .repository import WorldRepository from .scenarios import ( create_full_lifecycle_world, create_negotiation_world, @@ -79,6 +81,15 @@ def main() -> None: case.add_argument("--quarters", type=int, default=16) case.add_argument("--seed", type=int, default=42) case.add_argument("--fiscal-shock", type=float, default=0.0, help="optional Q4 available-budget multiplier, e.g. 0.7") + sensitivity = sub.add_parser( + "case-hefei-nio-sensitivity", + help="run 15%/25%/35% fiscal-space sensitivity with and without joint funds", + ) + sensitivity.add_argument("--quarters", type=int, default=16) + sensitivity.add_argument("--seed", type=int, default=42) + demo = sub.add_parser("demo", help="build the complete five-minute demo bundle") + demo.add_argument("--seed", type=int, default=42) + demo.add_argument("--fiscal-multiplier", type=float, default=0.5) args = parser.parse_args() if args.command == "compare": print(json.dumps(run_comparison(), ensure_ascii=False, indent=2)) @@ -168,6 +179,27 @@ def main() -> None: }, }, ensure_ascii=False, indent=2, default=str)) return + if args.command == "case-hefei-nio-sensitivity": + print(json.dumps( + run_hefei_nio_sensitivity(args.seed, quarters=args.quarters), + ensure_ascii=False, + indent=2, + )) + return + if args.command == "demo": + bundle = create_hefei_nio_demo( + WorldRepository(), args.seed, args.fiscal_multiplier, + ) + print(json.dumps({ + "generated_at": bundle["generated_at"], + "source": bundle["source"], + "baseline_world_id": bundle["baseline"].id, + "branch_world_id": bundle["branch"].id, + "comparison": bundle["comparison"], + "replay": bundle["replay"], + "sensitivity": bundle["sensitivity"], + }, ensure_ascii=False, indent=2, default=str)) + return engine = SimulationEngine(create_full_lifecycle_world(args.seed)) engine.run(args.quarters) summary = { diff --git a/src/insidegov/demo.py b/src/insidegov/demo.py new file mode 100644 index 0000000..45d943c --- /dev/null +++ b/src/insidegov/demo.py @@ -0,0 +1,134 @@ +"""One-click, rule-generated competition demonstration bundle.""" + +from __future__ import annotations + +import copy +import uuid +from dataclasses import asdict +from datetime import UTC, datetime + +from .cases import create_hefei_nio_world, run_hefei_nio_sensitivity +from .engine import SimulationEngine +from .p2 import compare_worlds +from .repository import WorldRepository +from .serde import world_from_dict + + +def create_hefei_nio_demo( + repository: WorldRepository, + seed: int = 42, + fiscal_multiplier: float = 0.5, +) -> dict: + """Run a baseline and a common-random-number fiscal counterfactual to Q16.""" + + token = uuid.uuid4().hex[:8] + root = create_hefei_nio_world(seed, f"demo-hefei-nio-root-{token}") + root_engine = SimulationEngine(root) + _save(repository, root_engine) + for _ in range(3): + root_engine.step() + _save(repository, root_engine) + + engines = [] + for label in ("baseline", "fiscal-shock"): + cloned = world_from_dict(root_engine.world.to_dict()) + cloned.id = f"demo-hefei-nio-{label}-{token}" + cloned.name = f"2020合肥—蔚来五分钟演示 / {label}" + cloned.parent_id = root_engine.world.id + cloned.branched_from_quarter = 3 + engine = SimulationEngine(cloned) + if label == "fiscal-shock": + engine.intervene("budget_multiply", "city_lin", fiscal_multiplier, 4) + _save(repository, engine) + for _ in range(13): + engine.step() + _save(repository, engine) + engines.append(engine) + + baseline, branch = engines + return { + "schema_version": "1.0", + "generated_at": datetime.now(UTC).isoformat(), + "source": "live_deterministic_simulation", + "seed": seed, + "common_ancestor_quarter": 3, + "intervention": { + "quarter": 4, + "target": "city_lin.available_budget", + "operation": "multiply", + "value": fiscal_multiplier, + }, + "baseline": baseline.world, + "branch": branch.world, + "comparison": compare_worlds(baseline.world, branch.world), + "replay": _replay(baseline.world, branch.world), + "sensitivity": run_hefei_nio_sensitivity(seed=seed), + } + + +def _save(repository: WorldRepository, engine: SimulationEngine) -> None: + repository.save(engine.world) + repository.save_snapshot(engine.world) + + +def _replay(baseline, branch) -> dict: + selected_id = baseline.selected_city_id or "city_lin" + selected_name = baseline.cities[selected_id].name + meeting = next( + item for item in reversed(baseline.negotiations) + if item.city_id == selected_id and item.quarter == 3 + ) + outward = next( + item for item in reversed(baseline.external_negotiations) + if item.city_id == selected_id and item.quarter == 3 + ) + audits = [ + item for item in baseline.action_audits + if item.quarter == 3 and item.agent_id in { + meeting.proposer_id, meeting.reviewer_id, meeting.coordinator_id, + "firm_nova_board", + } + ] + baseline_final = baseline.history[-1] + branch_final = branch.history[-1] + fund_rows = [asdict(item) for item in baseline.investment_funds.values()] + return { + "selected_city_id": selected_id, + "selected_city_name": selected_name, + "external_negotiation": asdict(outward), + "internal_negotiation": asdict(meeting), + "synthetic_private_audit": [ + { + "audit_id": item.id, + "agent_id": item.agent_id, + "private_context_used": copy.deepcopy(item.private_context_used), + "observation": copy.deepcopy(item.observation), + "llm_suggestion": copy.deepcopy(item.llm_suggestion), + "rule_adjustment": copy.deepcopy(item.rule_adjustment), + "executed_action": copy.deepcopy(item.executed_action), + "rationale": item.rationale, + "reflection": item.reflection, + "provider": item.provider, + "fallback": item.fallback, + } + for item in audits + ], + "funding_partners": fund_rows, + "promises": [asdict(item) for item in baseline.promises], + "baseline_outcome": asdict(baseline_final), + "branch_outcome": asdict(branch_final), + "causal_events": [ + asdict(item) for item in branch.events + if item.quarter >= 4 and item.kind in { + "intervention", "promise", "entry", "milestone" + } + ], + "steps": [ + {"id": "world", "title": "公开事实与模型假设", "quarter": 0}, + {"id": "external", "title": "企业表达与政府澄清", "quarter": 3}, + {"id": "internal", "title": "招商—财政—领导内部会商", "quarter": 3}, + {"id": "contract", "title": "条件承诺与联合基金", "quarter": 3}, + {"id": "delivery", "title": "跨期履约与规则结算", "quarter": 4}, + {"id": "counterfactual", "title": "共同随机条件下的反事实", "quarter": 16}, + ], + } diff --git a/src/insidegov/engine.py b/src/insidegov/engine.py index db2a039..4e2e502 100644 --- a/src/insidegov/engine.py +++ b/src/insidegov/engine.py @@ -24,6 +24,7 @@ MemoryRecord, MetricsSnapshot, NegotiationRound, + PaymentTranche, Phase, PolicyPackage, Promise, @@ -170,6 +171,9 @@ def _run_recruitment(self) -> None: float(prior_counter.counter_terms.get("equity_floor", 0.0)), ) proposal.talent_support = min(1.0, proposal.talent_support + 0.04) + fund_review = self._review_joint_investment(city, anchor, investment) + proposal.external_equity = fund_review["approved_total"] + proposal.fund_allocations = fund_review["allocations"] finance_observation = self._finance_observation(city, proposal) if self.world.mechanisms.get("internal_governance", True): review = self._cognitive_call( @@ -223,6 +227,16 @@ def _run_recruitment(self) -> None: review_suggestion = self._action_dict(review) resolution_meta = {"fallback": False, "diagnostics": []} resolution_suggestion = self._offer_tools(offer) + offer.external_equity = proposal.external_equity + offer.fund_allocations = copy.deepcopy(proposal.fund_allocations) + for index, (fund_id, amount) in enumerate(offer.fund_allocations.items(), start=1): + offer.payment_schedule.append(PaymentTranche( + "external_equity", + round(amount, 2), + min(3, index), + "contract_signed" if index == 1 else "equipment_ordered", + funding_source_id=fund_id, + )) city.active_offer = offer anchor.observed_offers[city.id] = offer self.world.negotiations.append(NegotiationRound( @@ -246,6 +260,13 @@ def _run_recruitment(self) -> None: turns=[ {"actor_id": investment.id, "act": "proposal", "summary": proposal_action.rationale, "amount": round(proposal.fiscal_cost, 3)}, + *([{ + "actor_id": "joint_investment_committee", + "act": "fund_review", + "summary": fund_review["rationale"], + "amount": fund_review["approved_total"], + "approved": fund_review["approved_total"] > 0, + }] if fund_review["requested_total"] > 0 else []), {"actor_id": finance.id, "act": "review", "summary": review.rationale, "approved": review.approved, "amount": review.maximum_fiscal_cost}, {"actor_id": leader.id, "act": "coordination", "summary": resolution.rationale if self.world.mechanisms.get("internal_governance", True) else "直接批准", @@ -408,7 +429,11 @@ def _firm_city_utility(self, firm: FirmState, city_id: str) -> float: cluster_sensitivity = 0.62 credibility_sensitivity = 0.62 intent_adjustment = 0.0 - policy_value = offer.subsidy * 0.6 + offer.equity * 0.36 + offer.land_discount * 42 + policy_value = ( + offer.subsidy * 0.6 + + offer.total_equity_support * 0.36 + + offer.land_discount * 42 + ) fundamentals = city.supply_chain * cluster_sensitivity + city.talent_pool * 0.28 site_fit = ( min(city.industrial_land / max(firm.land_need, 1.0), 3.0) * 4 @@ -501,6 +526,13 @@ def _select_city(self, anchor: FirmState, city_id: str, scores: dict[str, float] city.landed_firms.append(anchor.id) city.industrial_land -= anchor.land_need city.committed_expenditure += offer.fiscal_cost + for fund_id, amount in offer.fund_allocations.items(): + fund = self.world.investment_funds.get(fund_id) + if fund is None or amount <= 0: + continue + committed = min(amount, fund.available_capital) + fund.available_capital -= committed + fund.committed_capital += committed schedule = offer.payment_schedule or [ type("Tranche", (), {"item": "equity", "amount": offer.equity, "due_offset": 1, "condition": "contract_signed"})(), type("Tranche", (), {"item": "subsidy", "amount": offer.subsidy, "due_offset": 3, "condition": "project_progress>=0.55"})(), @@ -512,6 +544,7 @@ def _select_city(self, anchor: FirmState, city_id: str, scores: dict[str, float] item=tranche.item, amount=tranche.amount, due_quarter=self.world.quarter + tranche.due_offset, condition=tranche.condition, + funding_source_id=getattr(tranche, "funding_source_id", None), )) alternatives = [f"{self.world.cities[c].name}: {s:.1f}" for c, s in scores.items()] self._trace(anchor.id, "select_location", city_id, @@ -567,6 +600,39 @@ def _settle_promises(self) -> None: if not condition_met: continue due = promise.amount - promise.paid_amount + if promise.funding_source_id: + fund = self.world.investment_funds.get(promise.funding_source_id) + payment = min(due, fund.committed_capital if fund else 0.0) + if fund is not None and payment >= due - 1e-9: + fund.committed_capital = max(0.0, fund.committed_capital - payment) + anchor.cash += payment + promise.paid_amount += payment + promise.status = PromiseStatus.FULFILLED + city.objective_credibility = min(1.0, city.objective_credibility + 0.012) + self._update_perceptions(city.id, +0.018) + self._event( + "promise", + "联合产业基金按期出资", + f"{fund.name}支付 {payment:.1f} 亿元股权投资", + fund.id, + anchor.id, + "success", + ) + else: + promise.status = PromiseStatus.DELAYED + promise.delayed_quarters += 1 + promise.due_quarter += 1 + city.objective_credibility = max(0.25, city.objective_credibility - 0.035) + self._update_perceptions(city.id, -0.055) + self._event( + "promise", + "联合产业基金出资延期", + f"{promise.funding_source_id}未能足额支付 {due:.1f} 亿元", + promise.funding_source_id, + anchor.id, + "warning", + ) + continue finance = self.world.agents.get(f"{city.id}_finance") reserve_floor = ( float(finance.private_facts.get("reserve_floor", city.available_budget * 0.34)) @@ -787,11 +853,58 @@ def _hard_finance_constraints(self, city, finance) -> dict[str, float]: "first_period_payment": round(min(spendable * 0.12, city.available_budget * 0.075), 2), } + def _review_joint_investment(self, city, anchor, investment_agent) -> dict: + target = max( + 0.0, + float(investment_agent.private_facts.get("external_fund_target", 0.0)), + ) + funds = [ + fund for fund in self.world.investment_funds.values() + if fund.city_id == city.id and fund.available_capital > 0 + ] + if target <= 0 or not funds: + return { + "requested_total": target, + "approved_total": 0.0, + "allocations": {}, + "rationale": "本轮没有可独立决策的联合产业基金参与", + } + project_score = ( + anchor.technology * 0.40 + + anchor.private_intent * 100 * 0.25 + + min(100.0, anchor.investment_capacity / 2.5) * 0.20 + + city.supply_chain * 0.15 + ) / 100 + allocations: dict[str, float] = {} + remaining = target + for fund in sorted(funds, key=lambda item: item.risk_tolerance, reverse=True): + if project_score < fund.due_diligence_threshold: + continue + allocation = min(fund.available_capital, remaining) + if allocation > 0: + allocations[fund.id] = round(allocation, 2) + remaining -= allocation + if remaining <= 0.005: + break + approved = round(sum(allocations.values()), 2) + return { + "requested_total": round(target, 2), + "approved_total": approved, + "allocations": allocations, + "project_score": round(project_score, 3), + "rationale": ( + f"独立产业基金按项目质量和自身风控审核,申请 {target:.2f} 亿元," + f"核准 {approved:.2f} 亿元;该额度不占用市财政现金上限" + ), + } + @staticmethod def _offer_tools(offer: PolicyPackage) -> dict[str, float]: return { "subsidy": round(offer.subsidy, 3), "equity": round(offer.equity, 3), + "external_equity": round(offer.external_equity, 3), + "total_equity_support": round(offer.total_equity_support, 3), "credit_support": round(offer.credit_support, 3), "land_discount": round(offer.land_discount, 3), "fiscal_cost": round(offer.fiscal_cost, 3), diff --git a/src/insidegov/models.py b/src/insidegov/models.py index e479af3..b0ec9ab 100644 --- a/src/insidegov/models.py +++ b/src/insidegov/models.py @@ -68,6 +68,7 @@ class PaymentTranche: amount: float due_offset: int condition: str + funding_source_id: str | None = None @dataclass(slots=True) @@ -79,6 +80,8 @@ class PolicyPackage: credit_support: float approval_speed: float talent_support: float + external_equity: float = 0.0 + fund_allocations: dict[str, float] = field(default_factory=dict) conditions: dict[str, float] = field(default_factory=dict) payment_schedule: list[PaymentTranche] = field(default_factory=list) @@ -86,6 +89,10 @@ class PolicyPackage: def fiscal_cost(self) -> float: return self.subsidy + self.equity + self.credit_support * 0.08 + @property + def total_equity_support(self) -> float: + return self.equity + self.external_equity + @dataclass(slots=True) class DepartmentState: @@ -96,6 +103,20 @@ class DepartmentState: influence: float +@dataclass(slots=True) +class InvestmentFundState: + """A separately governed public-capital vehicle that can co-invest with a city.""" + + id: str + name: str + city_id: str + source_level: str + available_capital: float + risk_tolerance: float + due_diligence_threshold: float + committed_capital: float = 0.0 + + @dataclass(slots=True) class CityState: id: str @@ -168,6 +189,7 @@ class Promise: status: PromiseStatus = PromiseStatus.PENDING paid_amount: float = 0.0 delayed_quarters: int = 0 + funding_source_id: str | None = None @dataclass(slots=True) @@ -368,6 +390,7 @@ class WorldState: action_audits: list[AgentActionAudit] history: list[MetricsSnapshot] interventions: list[Intervention] + investment_funds: dict[str, InvestmentFundState] = field(default_factory=dict) intervention_plans: list[InterventionPlan] = field(default_factory=list) random_state: Any | None = None branched_from_quarter: int | None = None diff --git a/src/insidegov/reporting.py b/src/insidegov/reporting.py new file mode 100644 index 0000000..0fe1518 --- /dev/null +++ b/src/insidegov/reporting.py @@ -0,0 +1,159 @@ +"""Experiment report discovery and auditable run drill-down.""" + +from __future__ import annotations + +import copy +import json +import os +import re +from pathlib import Path + +from .models import WorldState +from .serde import world_from_dict + +SAFE_ID = re.compile(r"^[A-Za-z0-9._-]+$") + + +class ExperimentReportRepository: + def __init__( + self, + report_root: str | Path | None = None, + matrix_root: str | Path | None = None, + bundled_artifact: str | Path | None = None, + bundled_runs: str | Path | None = None, + ) -> None: + self.report_root = Path( + report_root or os.getenv("INSIDEGOV_REPORT_DIR", ".insidegov/reports") + ) + self.matrix_root = Path( + matrix_root or os.getenv("INSIDEGOV_MATRIX_WORLD_DIR", ".insidegov/matrix-worlds") + ) + self.bundled_artifact = Path( + bundled_artifact or "docs/reports/p1-visual-report/artifact.json" + ) + self.bundled_runs = Path( + bundled_runs or "docs/reports/p1-visual-report/runs.json" + ) + + def list(self) -> list[dict]: + items: list[dict] = [] + if self.report_root.exists(): + for path in sorted( + self.report_root.glob("*.json"), + key=lambda item: item.stat().st_mtime, + reverse=True, + ): + try: + report = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + items.append(self._summary(path.stem, report, "local_matrix")) + if self.bundled_runs.exists(): + report = json.loads(self.bundled_runs.read_text(encoding="utf-8")) + items.append(self._summary("p1-public-runs", report, "bundled_public_runs")) + if self.bundled_artifact.exists(): + artifact = json.loads(self.bundled_artifact.read_text(encoding="utf-8")) + items.append({ + "id": "p1-visual-bundled", + "title": artifact.get("manifest", {}).get( + "title", "P1 多策略与机制消融可视化报告" + ), + "kind": "p1_visual", + "source": "bundled_release_artifact", + "generated_at": artifact.get("manifest", {}).get("generatedAt"), + "strategy_runs": 0, + "ablation_runs": 0, + "failures": 0, + "drilldown_available": False, + }) + return items + + def load(self, report_id: str) -> dict | None: + if report_id == "p1-public-runs" and self.bundled_runs.exists(): + report = json.loads(self.bundled_runs.read_text(encoding="utf-8")) + return self._enrich(report_id, report, "bundled_public_runs") + if report_id == "p1-visual-bundled" and self.bundled_artifact.exists(): + return self._load_bundled() + if not SAFE_ID.fullmatch(report_id): + return None + target = self.report_root / f"{report_id}.json" + if not target.exists(): + return None + report = json.loads(target.read_text(encoding="utf-8")) + return self._enrich(report_id, report, "local_matrix") + + def _enrich(self, report_id: str, report: dict, source: str) -> dict: + enriched = copy.deepcopy(report) + for group, prefix in ( + ("strategy_runs", "matrix"), + ("ablation_runs", "matrix-ablation"), + ): + for run in enriched.get(group, []): + world_id = run.get("world_id") or ( + f"{prefix}-{run.get('strategy')}-seed-{run.get('seed')}" + ) + run["world_id"] = world_id + run["world_available"] = (self.matrix_root / f"{world_id}.json").exists() + enriched["report_id"] = report_id + enriched["report_summary"] = self._summary(report_id, enriched, source) + return enriched + + def load_world(self, world_id: str) -> WorldState | None: + if not SAFE_ID.fullmatch(world_id): + return None + target = self.matrix_root / f"{world_id}.json" + if not target.exists(): + return None + return world_from_dict(json.loads(target.read_text(encoding="utf-8"))) + + @staticmethod + def _summary(report_id: str, report: dict, source: str) -> dict: + if "strategy_summary" in report: + kind = "p1_matrix" + title = "P1 多策略、随机种子与机制消融" + elif "negotiation_summary" in report: + kind = "negotiation_matrix" + title = "七种政企协商协议实验" + elif "talent_summary" in report: + kind = "talent_matrix" + title = "人才对接 2×2 实验" + else: + kind = "experiment" + title = report_id + return { + "id": report_id, + "title": title, + "kind": kind, + "source": source, + "generated_at": report.get("generated_at") or report.get("updated_at"), + "strategy_runs": len(report.get("strategy_runs", [])), + "ablation_runs": len(report.get("ablation_runs", [])), + "failures": len(report.get("failure_cases", [])), + "drilldown_available": bool( + report.get("strategy_runs") or report.get("ablation_runs") + ), + } + + def _load_bundled(self) -> dict: + artifact = json.loads(self.bundled_artifact.read_text(encoding="utf-8")) + datasets = artifact.get("snapshot", {}).get("datasets", {}) + return { + "report_id": "p1-visual-bundled", + "schema_version": "visual-artifact-1", + "generated_at": artifact.get("manifest", {}).get("generatedAt"), + "configuration": {"source": "bundled_release_artifact"}, + "strategy_summary": datasets.get("strategy_comparison", []), + "ablation_summary": datasets.get("mechanism_effects", []), + "strategy_runs": [], + "ablation_runs": [], + "failure_cases": [], + "llm_quality": datasets.get("llm_quality", []), + "headline": datasets.get("headline", []), + "report_summary": { + "id": "p1-visual-bundled", + "title": artifact.get("manifest", {}).get("title"), + "kind": "p1_visual", + "source": "bundled_release_artifact", + "drilldown_available": False, + }, + } diff --git a/src/insidegov/serde.py b/src/insidegov/serde.py index bf0aefc..359e1d9 100644 --- a/src/insidegov/serde.py +++ b/src/insidegov/serde.py @@ -17,6 +17,7 @@ Intervention, InterventionChange, InterventionPlan, + InvestmentFundState, LatentNeed, MemoryRecord, MetricsSnapshot, @@ -100,6 +101,10 @@ def world_from_dict(data: dict) -> WorldState: **item, "changes": [InterventionChange(**change) for change in item.get("changes", [])], }) for item in data.get("intervention_plans", [])], + investment_funds={ + key: InvestmentFundState(**item) + for key, item in data.get("investment_funds", {}).items() + }, random_state=data.get("random_state"), branched_from_quarter=data.get("branched_from_quarter"), parameter_provenance=data.get("parameter_provenance", {}), diff --git a/tests/test_demo_workbench.py b/tests/test_demo_workbench.py new file mode 100644 index 0000000..266736f --- /dev/null +++ b/tests/test_demo_workbench.py @@ -0,0 +1,72 @@ +import json + +from insidegov.demo import create_hefei_nio_demo +from insidegov.engine import SimulationEngine +from insidegov.experiments import run_experiment_matrix +from insidegov.reporting import ExperimentReportRepository +from insidegov.repository import WorldRepository +from insidegov.scenarios import create_full_lifecycle_world + + +def test_guided_demo_is_generated_from_common_history_and_persisted(tmp_path): + repository = WorldRepository(tmp_path / "worlds") + bundle = create_hefei_nio_demo(repository, seed=42, fiscal_multiplier=0.5) + baseline = bundle["baseline"] + branch = bundle["branch"] + assert bundle["source"] == "live_deterministic_simulation" + assert baseline.parent_id == branch.parent_id + assert baseline.branched_from_quarter == branch.branched_from_quarter == 3 + assert baseline.quarter == branch.quarter == 16 + assert repository.load(baseline.id) is not None + assert repository.snapshot_quarters(branch.id) == list(range(3, 17)) + assert len(bundle["replay"]["steps"]) == 6 + assert bundle["replay"]["synthetic_private_audit"] + assert bundle["replay"]["internal_negotiation"]["final_tools"][ + "total_equity_support" + ] == 70.0 + assert bundle["comparison"]["delta"] + + +def test_experiment_report_repository_drills_into_seed_world(tmp_path): + report_root = tmp_path / "reports" + matrix_root = tmp_path / "matrix-worlds" + report_root.mkdir() + matrix_root.mkdir() + world = SimulationEngine(create_full_lifecycle_world(11, "matrix-deterministic-seed-11")).run(4) + (matrix_root / f"{world.id}.json").write_text( + json.dumps(world.to_dict(), ensure_ascii=False, default=str), encoding="utf-8" + ) + report = run_experiment_matrix( + seeds=[11, 23], quarters=4, include_llm=False, save_report=False, + ) + report_id = "p1-matrix-test" + (report_root / f"{report_id}.json").write_text( + json.dumps(report, ensure_ascii=False, default=str), encoding="utf-8" + ) + repository = ExperimentReportRepository( + report_root=report_root, + matrix_root=matrix_root, + bundled_artifact=tmp_path / "missing.json", + ) + loaded = repository.load(report_id) + assert loaded is not None + first = loaded["strategy_runs"][0] + assert first["world_id"] == "matrix-deterministic-seed-11" + assert first["world_available"] is True + assert repository.load_world(first["world_id"]) is not None + + +def test_bundled_public_report_keeps_seed_drilldown_without_private_archives(tmp_path): + repository = ExperimentReportRepository( + report_root=tmp_path / "missing-reports", + matrix_root=tmp_path / "missing-worlds", + ) + summaries = repository.list() + bundled = next(item for item in summaries if item["id"] == "p1-public-runs") + assert bundled["kind"] == "p1_matrix" + assert bundled["strategy_runs"] == 10 + report = repository.load("p1-public-runs") + assert report is not None + assert len(report["ablation_runs"]) == 25 + assert report["failure_cases"][0]["seed"] == 57 + assert all(not item["world_available"] for item in report["strategy_runs"]) diff --git a/tests/test_real_case.py b/tests/test_real_case.py index e62d131..a9b18b7 100644 --- a/tests/test_real_case.py +++ b/tests/test_real_case.py @@ -1,5 +1,8 @@ -from insidegov.cases import create_hefei_nio_world +import pytest + +from insidegov.cases import create_hefei_nio_world, run_hefei_nio_sensitivity from insidegov.engine import SimulationEngine +from insidegov.serde import world_from_dict def test_hefei_nio_case_keeps_public_facts_and_runs_normally(): @@ -16,3 +19,32 @@ def test_hefei_nio_case_keeps_public_facts_and_runs_normally(): assert result.negotiations assert result.action_audits assert result.history[-1].cluster_size >= 1 + + +def test_joint_funds_are_independent_promises_and_close_the_historical_equity_gap(): + result = SimulationEngine(create_hefei_nio_world(42)).run(16) + offer = result.cities["city_lin"].active_offer + assert offer is not None + assert offer.equity == 16.48 + assert offer.external_equity == 53.52 + assert offer.total_equity_support == 70.0 + assert len(result.investment_funds) == 3 + fund_promises = [item for item in result.promises if item.funding_source_id] + assert len(fund_promises) == 3 + assert sum(item.amount for item in fund_promises) == pytest.approx(53.52) + assert all(item.status.value == "fulfilled" for item in fund_promises) + restored = world_from_dict(result.to_dict()) + assert restored.investment_funds["fund_cmg_sdic"].source_level == "national_industrial_fund" + + +def test_hefei_sensitivity_compares_fiscal_space_and_joint_investment(): + report = run_hefei_nio_sensitivity(42) + assert len(report["runs"]) == 6 + without = [item for item in report["runs"] if not item["joint_investment"]] + with_funds = [item for item in report["runs"] if item["joint_investment"]] + assert all(item["historical_equity_gap"] > 45 for item in without) + assert all( + abs(joint["historical_equity_gap"]) < municipal["historical_equity_gap"] + for municipal, joint in zip(without, with_funds, strict=True) + ) + assert with_funds[1]["historical_equity_gap"] == pytest.approx(0.0) diff --git a/uv.lock b/uv.lock index aeaaaa5..50fb479 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,7 @@ wheels = [ [[package]] name = "insidegov" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, From bff1ff0861567b6b4e9671e6239bcbd62b15f05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BC=A0=E6=AC=A3?= Date: Thu, 13 Aug 2026 01:32:37 +0800 Subject: [PATCH 2/3] feat(web): add guided replay and report center --- apps/web/README.md | 4 +- apps/web/app/components/DemoReplay.tsx | 152 ++++ apps/web/app/components/ReportCenter.tsx | 88 ++ apps/web/app/globals.css | 3 +- apps/web/app/lib/api.ts | 90 +- apps/web/app/page.tsx | 42 +- apps/web/package-lock.json | 4 +- apps/web/package.json | 2 +- docs/reports/p1-visual-report/runs.json | 1029 ++++++++++++++++++++++ 9 files changed, 1383 insertions(+), 31 deletions(-) create mode 100644 apps/web/app/components/DemoReplay.tsx create mode 100644 apps/web/app/components/ReportCenter.tsx create mode 100644 docs/reports/p1-visual-report/runs.json diff --git a/apps/web/README.md b/apps/web/README.md index f14fa39..43d30d3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,6 +1,6 @@ # InsideGov Console -InsideGov 的交互式推演控制台。页面数据全部来自 Python API,支持推进、干预、分支、恢复与导出。 +InsideGov 的交互式推演控制台。页面数据全部来自 Python API,支持推进、干预、分支、恢复与导出。v0.5 首页提供“进入五分钟演示”和“实验结果”两个主入口:前者实时生成典型博弈与反事实,后者支持矩阵均值、seed、消融和失败案例下钻。 ```bash npm install @@ -9,4 +9,6 @@ npm run dev 启动前需在仓库根目录运行 `uv run uvicorn insidegov.api:app --reload`。如 API 不在 `http://localhost:8000`,设置 `NEXT_PUBLIC_INSIDEGOV_API_URL`。 +也可在仓库根目录运行 `make demo-stack` 一键启动前后端。确定性演示不需要API Key。 + 需要 Node.js 22.13 或更高版本。 diff --git a/apps/web/app/components/DemoReplay.tsx b/apps/web/app/components/DemoReplay.tsx new file mode 100644 index 0000000..a0efcf3 --- /dev/null +++ b/apps/web/app/components/DemoReplay.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { API_BASE, api, type DemoBundle, type World } from "../lib/api"; + +type Props = { + remember: (world: World) => void; + refreshList: () => Promise; +}; + +export function DemoReplay({ remember, refreshList }: Props) { + const [bundle, setBundle] = useState(null); + const [step, setStep] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function runDemo() { + setLoading(true); + setError(""); + try { + const result = await api.createDemo(); + setBundle(result); + setStep(0); + remember(result.baseline); + await refreshList(); + } catch (caught) { + setError(`演示生成失败:${String(caught)}`); + } finally { + setLoading(false); + } + } + + if (!bundle) { + return
+ LIVE · FIVE-MINUTE EVIDENCE CHAIN +

五分钟典型政企博弈回放

+

系统将现场运行合肥—蔚来校准世界:先推进共同历史,再从 Q3 复制基线与财政冲击分支,保持随机状态一致并运行至 Q16。

+
    +
  • 不是前端预写剧情,每次都会生成新的世界 ID 和完整快照。
  • +
  • 展示企业表达、政府内部会商、独立产业基金、条件承诺与规则结算。
  • +
  • 确定性模式无需网络和模型 Key,比赛现场可稳定复现。
  • +
+ + {error &&

{error}

} +
; + } + + const current = bundle.replay.steps[step]; + const meeting = bundle.replay.internal_negotiation; + const outward = bundle.replay.external_negotiation; + const comparison = bundle.comparison.delta; + + return
+
+ 实时规则运行 + {bundle.baseline.id} + {bundle.branch.id} + Seed {bundle.seed} · 共同祖先 Q{bundle.common_ancestor_quarter} +
+
    + {bundle.replay.steps.map((item, index) =>
  1. + +
  2. )} +
+ +
+
STEP {step + 1} / 6

{current.title}

+ {current.id === "world" &&
+
公开资料

合肥一般公共预算收入、企业资产与现金投入、12 万辆年产能、70 亿元历史股权投资均保留来源。

+
专家映射

可调度财力默认取财政收入 25%;供应链、行政能力和信用指数均明确标为映射,不冒充公开事实。

+
模型假设

就业容量和供应商进入窗口缺少企业级公开数据,报告中始终标注外推边界。

+
} + {current.id === "external" &&
+

企业表面诉求:{outward.stated_need}

+

政府追问:{outward.government_questions.join("、")}

+

认知更新:{compact(outward.belief_before)} → {compact(outward.belief_after)}

+

企业回应:{outward.enterprise_response} · {outward.enterprise_rationale}

+
} + {current.id === "internal" && <> +
+
招商局提案现金 {money(meeting.proposal_tools.subsidy)}本级股权 {money(meeting.proposal_tools.equity)}联合基金申请 {money(meeting.proposal_tools.external_equity)}
+ → +
财政局硬约束现金上限 {money(meeting.finance_tool_limits.subsidy)}本级股权上限 {money(meeting.finance_tool_limits.equity)}{meeting.finance_approved ? "直接通过" : "要求重组"}
+ → +
市领导协调后现金 {money(meeting.final_tools.subsidy)}总股权 {money(meeting.final_tools.total_equity_support)}{meeting.payment_schedule.length} 个条件性支付节点
+
+
+

演示世界私有真值审计

+ {bundle.replay.synthetic_private_audit.map((item) =>
+ {item.agent_id} · {item.provider} · {item.fallback ? "发生降级" : "原生执行"} +

私有信息:{compact(item.private_context_used)}

+

当时观察:{compact(item.observation)}

+

认知建议:{compact(item.llm_suggestion)}

+

规则修正:{compact(item.rule_adjustment)}

+

最终执行:{compact(item.executed_action)}

+
)} +
+ } + {current.id === "contract" && <> +
{bundle.replay.funding_partners.map((fund) =>
{fund.source_level}{fund.name}

独立风控阈值 {(fund.due_diligence_threshold * 100).toFixed(0)}%

)}
+
{bundle.replay.promises.map((promise) =>
{promise.funding_source_id ? "联合基金股权" : toolName(promise.item)}{money(promise.amount)}Q{promise.due_quarter} · {promise.condition}{promise.status}
)}
+ } + {current.id === "delivery" &&
+ {bundle.replay.causal_events.slice(0, 12).map((event, index) =>
{event.title}

{event.detail}

)} +
} + {current.id === "counterfactual" && <> +
+
基线 Q16{bundle.replay.baseline_outcome.cluster_size} 家集群{bundle.replay.baseline_outcome.total_employment.toLocaleString()} 个就业
+
Q4 财力减半{bundle.replay.branch_outcome.cluster_size} 家集群{bundle.replay.branch_outcome.total_employment.toLocaleString()} 个就业
+
规则产生的差异{signed(comparison.cluster_size)} 家{signed(comparison.total_employment)} 个就业
+
+

财力冲击 → 本级承诺延期 → 客观信用与企业感知下降 → 边缘供应商未达到进入门槛。差值是模型内部机制结果,不是现实政策因果效应。

+ + } +
+ +
+ + + + 导出分支 JSON + {step < bundle.replay.steps.length - 1 + ? + : } +
+
; +} + +function SensitivityTable({ bundle }: { bundle: DemoBundle }) { + return
+

财政空间 × 联合基金敏感性

+
可调度财力联合基金总股权支持与历史 70 亿差距Q16 集群
+ {bundle.sensitivity.runs.map((run) =>
+ {(run.available_budget_share * 100).toFixed(0)}% + {run.joint_investment ? "开启" : "关闭"} + {money(run.total_equity_support)} + {money(run.historical_equity_gap)} + {run.cluster_size} 家 +
)} +
; +} + +function compact(value: unknown) { + const text = JSON.stringify(value); + return text.length > 420 ? `${text.slice(0, 417)}…` : text; +} + +function money(value?: number) { return value == null ? "—" : `${value.toFixed(2)} 亿`; } +function signed(value?: number) { return value == null ? "—" : `${value > 0 ? "+" : ""}${value.toLocaleString()}`; } +function toolName(item: string) { return ({ subsidy: "现金补贴", equity: "本级股权", credit_support_cost: "信贷支持成本" } as Record)[item] ?? item; } diff --git a/apps/web/app/components/ReportCenter.tsx b/apps/web/app/components/ReportCenter.tsx new file mode 100644 index 0000000..7523b46 --- /dev/null +++ b/apps/web/app/components/ReportCenter.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { API_BASE, api, type ExperimentReport, type ExperimentReportSummary, type ExperimentRun, type World } from "../lib/api"; + +export function ReportCenter({ remember }: { remember: (world: World) => void }) { + const [reports, setReports] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [report, setReport] = useState(null); + const [group, setGroup] = useState<"strategy" | "ablation">("strategy"); + const [run, setRun] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + void api.listExperimentReports().then((items) => { + if (!alive) return; + setReports(items); + const preferred = items.find((item) => item.kind === "p1_matrix") ?? items[0]; + if (preferred) setSelectedId(preferred.id); + }).catch((caught) => setError(String(caught))); + return () => { alive = false; }; + }, []); + + useEffect(() => { + if (!selectedId) return; + let alive = true; + void api.getExperimentReport(selectedId).then((result) => { + if (alive) setReport(result); + }).catch((caught) => setError(String(caught))); + return () => { alive = false; }; + }, [selectedId]); + + const summaries = group === "strategy" ? report?.strategy_summary ?? [] : report?.ablation_summary ?? []; + const runs = group === "strategy" ? report?.strategy_runs ?? [] : report?.ablation_runs ?? []; + const maxEmployment = Math.max(1, ...summaries.map((item) => metric(item, "total_employment", "employment"))); + + function selectReport(id: string) { + setSelectedId(id); + setReport(null); + setRun(null); + } + + async function openRunWorld(selected: ExperimentRun) { + if (!report || !selected.world_available) return; + remember(await api.getExperimentWorld(report.report_id, selected.world_id)); + } + + return
+ EXPERIMENT EVIDENCE CENTER +

可视化实验报告与 seed 下钻

+
+ +
+ {report && 导出 JSON} +
+ {error &&

{error}

} + {!report &&

正在读取实验报告…

} + {report && <> +
{String(report.generated_at ?? "发布内置报告")}{runs.length} 个可下钻运行{report.failure_cases.length} 个失败案例
+
{summaries.map((item, index) => { + const employment = metric(item, "total_employment", "employment"); + const cluster = metric(item, "cluster_size", "cluster"); + const label = item.name ?? item.strategy ?? item.mechanism ?? item.id ?? `实验 ${index + 1}`; + return ; + })}
+ {runs.length > 0 ?
{runs.map((item) => )}
:

内置发布报告只包含聚合数据。运行 uv run insidegov matrix 后,这里会自动出现每个 seed 及其完整世界。

} + {run &&
+
SINGLE RUN

{run.strategy} · Seed {run.seed}

{run.successful ? "成功样本" : "失败样本"}
+

选址{run.selected_city ?? "未落地"}

就业{run.total_employment}

集群{run.cluster_size}

兑现{run.fulfilled_promises}

信用{(run.average_credibility * 100).toFixed(1)}%

方差来源Seed {run.seed}

+ +
} +

失败与降级案例

{report.failure_cases.length ? report.failure_cases.map((item, index) =>
{item.strategy} · Seed {item.seed}{item.stage}

{item.reason}

) :

本报告没有失败记录。

}
+ } +
; +} + +function metric(item: ExperimentReport["strategy_summary"][number], key: string, fallback: "employment" | "cluster") { + return item.metrics?.[key]?.mean ?? item[fallback] ?? 0; +} + +function sampleSize(item: ExperimentReport["strategy_summary"][number]) { + return item.metrics?.total_employment?.n ?? item.seeds ?? item.successful ?? 0; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d403301..364f015 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -16,9 +16,10 @@ button:disabled { opacity:.5;cursor:not-allowed!important; } .dashboard-grid { display:grid;grid-template-columns:290px minmax(610px,1fr) 330px;gap:12px;padding:12px;max-width:1720px;margin:0 auto }.panel { background:linear-gradient(150deg,#122226,#0d191c);border:1px solid var(--line);box-shadow:0 12px 40px #0003 }.panel-heading { justify-content:space-between;padding:16px 17px;border-bottom:1px solid var(--line) }.panel-heading small { color:var(--cyan);font:9px var(--font-geist-mono);letter-spacing:.16em }.panel-heading h2 { font-size:15px;margin:4px 0 0 }.panel-heading>span,.legend { color:var(--muted);font-size:10px }.legend { display:flex;gap:12px }.left-rail { align-self:start }.city-list { padding:9px }.city-card { padding:13px;border:1px solid transparent;border-bottom-color:#203438;background:#0d1a1d;margin-bottom:7px }.city-card.selected { border-color:#3aa7a4;background:linear-gradient(110deg,#153033,#102024) }.city-head { gap:9px }.city-mark { width:32px;height:32px;display:grid;place-items:center;border-radius:4px;font-family:serif;font-size:17px;background:#233 }.city-mark.amber{color:var(--amber);background:#382e20}.city-mark.cyan{color:var(--cyan);background:#173b3b}.city-mark.violet{color:#ac9ce5;background:#2b2740}.city-head h3{font-size:14px;margin:0}.city-head p{font-size:9px;color:var(--muted);margin:3px 0 0}.city-head b{margin-left:auto;color:#071315;background:var(--cyan);font-size:9px;padding:4px 7px}.mini-metrics{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:12px 0 10px}.mini-metrics span{font-size:9px;color:var(--muted);background:#0a1517;padding:7px}.mini-metrics strong{display:block;color:var(--ink);font:12px var(--font-geist-mono);margin-top:3px}.city-card label{display:grid;grid-template-columns:1fr auto;gap:5px;font-size:9px;color:var(--muted);margin:8px 0}.city-card label em{font-style:normal;color:#b9c7ca}.city-card label i{grid-column:1/-1;height:3px;background:#26393d}.city-card label i b{display:block;height:100%;background:var(--cyan)}.offer{border-top:1px dashed #2b4146;margin-top:11px;padding-top:9px;font-size:9px;color:var(--muted)}.offer strong{float:right;color:var(--amber);font:12px var(--font-geist-mono)}.wide-button{width:calc(100% - 18px);margin:0 9px 10px;font-size:11px}.wide-button.accent{border-color:#477b79;color:var(--cyan);margin:12px 0 0;width:100%} .center-stage { display:grid;gap:12px;align-content:start }.metric-row { display:grid;grid-template-columns:repeat(4,1fr);gap:8px }.metric-card { border:1px solid var(--line);background:#101f22;padding:13px 14px;position:relative;overflow:hidden }.metric-card:after { content:"";position:absolute;top:0;left:0;right:0;height:2px;background:var(--cyan) }.metric-card small{display:block;color:var(--muted);font-size:9px}.metric-card strong{display:block;font:24px var(--font-geist-mono);margin:7px 0 4px}.metric-card span{font-size:9px;color:#82989c}.metric-card.amber:after{background:var(--amber)}.metric-card.green:after{background:var(--green)}.metric-card.red:after{background:var(--red)}.metric-card.violet:after{background:#a28be2}.world-map{min-height:440px}.map-canvas{height:384px;position:relative;overflow:hidden;background:radial-gradient(circle at 56% 48%,#183b3d 0,#0b181b 38%,#081315 72%)}.grid-lines{position:absolute;inset:0;opacity:.28;background-image:linear-gradient(#284348 1px,transparent 1px),linear-gradient(90deg,#284348 1px,transparent 1px);background-size:42px 42px;transform:perspective(500px) rotateX(58deg) scale(1.7);transform-origin:center bottom}.map-city,.cluster-core{position:absolute;z-index:4;display:flex;flex-direction:column;align-items:center}.map-city span{width:38px;height:38px;border:1px solid #5b777b;background:#142a2e;display:grid;place-items:center;border-radius:50%;font-family:serif}.map-city b,.cluster-core b{font-size:11px;margin-top:7px}.map-city small,.cluster-core small{font-size:8px;color:var(--muted);margin-top:3px}.map-hai{left:13%;top:20%}.map-yun{right:12%;top:22%}.cluster-core{left:50%;top:48%;transform:translate(-50%,-50%)}.anchor{font-size:38px;color:var(--amber);text-shadow:0 0 18px #e2b56a88;z-index:3}.rings i{position:absolute;border:1px solid #55bbb84d;border-radius:50%;left:50%;top:39%;transform:translate(-50%,-50%);animation:pulse 3s infinite}.rings i:nth-child(1){width:110px;height:110px}.rings i:nth-child(2){width:190px;height:190px;animation-delay:.6s}.rings i:nth-child(3){width:270px;height:270px;animation-delay:1.2s}@keyframes pulse{50%{opacity:.3;transform:translate(-50%,-50%) scale(1.06)}}.supplier{position:absolute;width:7px;height:7px;background:var(--cyan);border-radius:50%;box-shadow:0 0 8px var(--cyan)}.s1{transform:translate(-88px,-45px)}.s2{transform:translate(82px,-40px)}.s3{transform:translate(-110px,15px)}.s4{transform:translate(105px,22px)}.s5{transform:translate(-68px,67px)}.s6{transform:translate(64px,73px)}.s7{transform:translate(-135px,-76px)}.s8{transform:translate(139px,-70px)}.s9{transform:translate(-148px,65px)}.s10{transform:translate(151px,72px)}.flow{position:absolute;height:1px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);width:35%;top:39%;opacity:.45}.flow-a{left:17%;transform:rotate(17deg)}.flow-b{right:17%;transform:rotate(-18deg)}.map-caption{position:absolute;bottom:17px;left:17px;right:17px;border-left:2px solid var(--cyan);padding:8px 11px;background:#0a1517cc;display:flex;align-items:center;gap:12px}.map-caption strong{font-size:11px;color:var(--cyan)}.map-caption span{font-size:10px;color:#9aadb1}.chart-panel{height:250px}.alert{color:var(--amber)!important;border:1px solid #58472c;padding:4px 7px}.chart-area{height:145px;display:flex;align-items:flex-end;gap:5px;padding:18px 18px 0;border-bottom:1px solid #2b3e43}.chart-area button{height:100%;flex:1;border:0;background:transparent;position:relative;display:flex;align-items:flex-end;justify-content:center;gap:2px;cursor:pointer}.chart-area button i{display:block;width:35%;opacity:.7;min-height:1px}.chart-area button .demand{background:var(--cyan)}.chart-area button .capacity{background:var(--amber)}.chart-area button span{position:absolute;bottom:-17px;color:#597076;font:8px var(--font-geist-mono)}.chart-area button.active{background:#20343880}.chart-area button.active i{opacity:1}.chart-legend{padding:25px 17px 0;gap:14px;font-size:9px;color:var(--muted)}.chart-legend i{width:8px;height:8px;display:inline-block;margin-right:5px}.cyan-box{background:var(--cyan)}.amber-box{background:var(--amber)}.chart-legend strong{margin-left:auto;color:#6e858a;font-weight:400} .right-rail{display:grid;gap:12px;align-content:start}.experiment-panel>p{font-size:10px;line-height:1.6;color:var(--muted);margin:14px 16px}.branch-list{padding:0 11px}.branch-list button{width:100%;display:flex;align-items:center;text-align:left;border:1px solid transparent;background:#0c181a;color:var(--ink);padding:9px;margin-bottom:6px;cursor:pointer}.branch-list button.active{border-color:var(--cyan);background:#152d30}.branch-list button>i{width:25px;height:25px;border:1px solid #3a5055;display:grid;place-items:center;font-style:normal;font:10px var(--font-geist-mono);margin-right:9px}.branch-list button.active>i{background:var(--cyan);color:#071315}.branch-list span{display:flex;flex-direction:column}.branch-list strong{font-size:11px}.branch-list small{font-size:8px;color:var(--muted);margin-top:3px}.branch-list b{margin-left:auto;color:#617a7f}.event-panel{height:500px}.events{padding:7px 15px;max-height:438px;overflow:auto}.events article{display:grid;grid-template-columns:31px 1fr;gap:10px;border-left:1px solid #294046;padding:9px 0 9px 10px;position:relative}.events article:before{content:"";position:absolute;width:6px;height:6px;border-radius:50%;background:#5a7075;left:-4px;top:15px}.events time{font:9px var(--font-geist-mono);color:#71878c}.events small{font-size:8px;color:var(--cyan)}.events h3{font-size:10px;margin:3px 0}.events p{font-size:8px;color:var(--muted);margin:0;line-height:1.5}.events article.success:before{background:var(--green)}.events article.warning:before{background:var(--amber)}.events article.danger:before{background:var(--red)} -.intervention-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;padding:0 11px 12px}.intervention-grid button,.intervention-grid a{border:1px solid #3b4c50;background:#0c181a;color:var(--muted);font-size:8px;padding:8px 2px;cursor:pointer;text-align:center;text-decoration:none}.intervention-grid button:hover,.intervention-grid a:hover{color:var(--amber);border-color:var(--amber)} +.intervention-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:5px;padding:0 11px 12px}.intervention-grid button,.intervention-grid a{border:1px solid #3b4c50;background:#0c181a;color:var(--muted);font-size:8px;padding:8px 2px;cursor:pointer;text-align:center;text-decoration:none}.intervention-grid button:hover,.intervention-grid a:hover{color:var(--amber);border-color:var(--amber)} .modal textarea,.modal input{width:100%;box-sizing:border-box;background:#091315;border:1px solid #31474c;color:var(--ink);padding:10px;margin:8px 0;font:11px var(--font-geist-mono);resize:vertical}.modal article{margin-top:12px}.audit-list b{color:var(--cyan)} .trace-drawer{margin:0 12px 12px;max-width:1696px;margin-inline:auto}.trace-grid{padding:9px}.trace-grid article{display:grid;grid-template-columns:35px 135px 1.1fr 1.4fr 85px 120px;align-items:center;gap:12px;padding:11px;border-bottom:1px solid #203438}.trace-grid small{font-size:8px;color:var(--muted)}.trace-grid h3{font-size:11px;margin:3px 0}.trace-grid p{font-size:9px;margin:3px 0;color:#9fb0b4}.trace-no{font:11px var(--font-geist-mono);color:#496167}.trace-grid>article>strong{font:13px var(--font-geist-mono);color:var(--amber)}.trace-grid button{border:0;background:transparent;color:var(--cyan);font-size:9px;cursor:pointer}footer{height:42px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;padding:0 22px;color:#50676c;font-size:9px;max-width:1720px;margin:auto} .empty-chart{width:100%;align-self:center;text-align:center;color:var(--muted);font-size:11px}.modal-backdrop{position:fixed;inset:0;z-index:60;background:#020708d9;display:grid;place-items:center;padding:40px}.modal{width:min(760px,90vw);max-height:82vh;padding:28px;position:relative;overflow:auto}.modal>small{color:var(--cyan);font:9px var(--font-geist-mono);letter-spacing:.16em}.modal>h2{margin:7px 0 18px}.modal>p{color:#a0b0b4;line-height:1.7;font-size:12px}.modal-close{position:absolute;right:14px;top:12px;background:transparent;border:0;color:var(--muted);font-size:24px;cursor:pointer}.modal-list{display:grid;gap:8px}.modal-list article{background:#0a1517;border:1px solid var(--line);padding:13px;display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.modal-list article strong,.modal-list article p{grid-column:1/-1}.modal-list article span{font-size:10px;color:#a7b9bd}.modal-list article p{font-size:10px;color:var(--muted);margin:3px 0}.settings{display:grid;grid-template-columns:1fr 1fr;gap:12px}.settings label{display:grid;gap:7px;color:var(--muted);font-size:10px}.settings select{background:#0b181a;border:1px solid var(--line);color:var(--ink);padding:10px}.hint{color:var(--amber)!important;font-size:10px!important} .tranches{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:5px;border-top:1px dashed var(--line);padding-top:8px}.tranches i{font-style:normal;font-size:8px;color:var(--cyan);background:#102427;padding:5px 7px} +.demo-banner{max-width:1696px;margin:12px auto 0;border:1px solid #4f6653;background:linear-gradient(100deg,#142723,#0d1c1d 70%);min-height:72px;padding:12px 18px;display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:10px}.demo-banner div{display:grid;gap:3px}.demo-banner small{color:var(--green);font:9px var(--font-geist-mono);letter-spacing:.14em}.demo-banner strong{font-size:14px}.demo-banner span{color:var(--muted);font-size:9px}.demo-banner button{border:1px solid #42675a;background:#112723;color:var(--green);padding:10px 15px;cursor:pointer}.demo-banner button:first-of-type{background:var(--green);color:#07130f;font-weight:700}.modal-wide{width:min(1160px,94vw);max-height:90vh}.demo-intro{max-width:720px;margin:auto}.demo-intro ul{padding-left:20px;color:#9eb0b5;font-size:11px;line-height:1.8}.error-text{color:#ff9f97!important}.demo-replay{display:grid;gap:15px}.replay-proof{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px solid #31554b;background:#0b1b18;padding:10px}.replay-proof span{color:var(--green);font-weight:700;font-size:10px}.replay-proof code{font-size:9px;color:#9fc0b4;background:#071310;padding:5px 7px}.replay-proof b{margin-left:auto;color:var(--muted);font-size:9px}.replay-steps{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(6,1fr);gap:5px}.replay-steps button{width:100%;height:58px;border:1px solid var(--line);background:#0a1618;color:#71868b;display:flex;align-items:center;text-align:left;gap:7px;padding:8px;cursor:pointer}.replay-steps i{width:24px;height:24px;border:1px solid #34494e;border-radius:50%;display:grid;place-items:center;font-style:normal;font-size:9px}.replay-steps span{font-size:9px}.replay-steps small{display:block;margin-top:3px;color:#52676c}.replay-steps .active button{border-color:var(--cyan);color:var(--cyan);background:#11272a}.replay-steps .done button{border-color:#31544a;color:var(--green)}.replay-stage{min-height:440px;background:#091416;border:1px solid var(--line);padding:18px;overflow:auto}.replay-stage>header{border-bottom:1px solid var(--line);margin-bottom:14px}.replay-stage>header small{font:9px var(--font-geist-mono);color:var(--cyan)}.replay-stage>header h3{margin:5px 0 13px}.replay-grid,.fund-grid,.comparison-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.replay-grid article,.fund-grid article,.comparison-cards article{border:1px solid var(--line);background:#0d1c1f;padding:14px}.replay-grid strong,.fund-grid strong,.comparison-cards strong{display:block;color:var(--amber);font-size:14px}.replay-grid p,.fund-grid p{color:var(--muted);font-size:10px;line-height:1.7}.evidence-stack{display:grid;gap:8px}.evidence-stack p{margin:0;border-left:2px solid var(--cyan);background:#0d1b1e;padding:12px;font-size:11px;line-height:1.7;color:#9eafb3}.offer-chain{display:grid;grid-template-columns:1fr 24px 1fr 24px 1fr;gap:7px;align-items:center}.offer-chain>article{border:1px solid var(--line);background:#0d1d20;padding:12px;display:grid;gap:6px}.offer-chain>article small{color:var(--cyan)}.offer-chain>article strong{font-size:12px}.offer-chain>article span{font-size:9px;color:var(--muted)}.offer-chain>b{text-align:center;color:var(--amber)}.private-audit{margin-top:14px}.private-audit h4,.sensitivity-table h4{font-size:11px;color:var(--amber)}.private-audit details{border:1px solid var(--line);background:#0b181a;margin:6px 0;padding:10px}.private-audit summary{cursor:pointer;color:#a8babd;font-size:10px}.private-audit p{font-size:9px;color:var(--muted);overflow-wrap:anywhere}.fund-grid article small{display:block;color:var(--cyan);font-size:8px;margin-bottom:7px}.fund-grid article strong{color:var(--ink);font-size:11px}.promise-table{margin-top:12px;border:1px solid var(--line)}.promise-table>div{display:grid;grid-template-columns:1fr 100px 1.4fr 80px;gap:8px;padding:8px 11px;border-bottom:1px solid var(--line);align-items:center;font-size:9px}.promise-table b{color:var(--amber)}.promise-table em{font-style:normal;text-transform:uppercase;color:var(--green)}.event-chain{display:grid;grid-template-columns:repeat(2,1fr);gap:7px}.event-chain article{display:grid;grid-template-columns:35px 1fr;gap:9px;border:1px solid var(--line);background:#0c191b;padding:10px}.event-chain time{color:var(--cyan);font:9px var(--font-geist-mono)}.event-chain strong{font-size:10px}.event-chain p{font-size:9px;color:var(--muted);margin:5px 0 0}.comparison-cards article small{display:block;color:var(--muted);font-size:9px}.comparison-cards article strong{font:19px var(--font-geist-mono);margin:8px 0}.comparison-cards article span{font-size:10px}.comparison-cards .warning{border-color:#6d5932}.boundary{border-left:2px solid var(--amber);background:#1c1911;color:#b8aa8e;padding:11px;font-size:10px;line-height:1.7}.sensitivity-table{margin-top:14px}.table-row{display:grid;grid-template-columns:repeat(5,1fr);border-bottom:1px solid var(--line);padding:7px 10px;font-size:9px}.table-row.header{color:var(--muted);background:#102024}.table-row b{color:var(--amber)}.replay-actions{display:flex;gap:7px;height:auto;padding:0;border:0;max-width:none}.replay-actions button,.replay-actions a{border:1px solid #355056;background:#0c191b;color:#9fb2b6;padding:9px 12px;cursor:pointer;font-size:9px}.replay-actions .primary{margin-left:auto;background:var(--cyan);border-color:var(--cyan);color:#071315;font-weight:700}.report-center{display:grid;gap:12px}.report-center>small{color:var(--cyan);font:9px var(--font-geist-mono);letter-spacing:.16em}.report-center>h2{margin:0}.report-toolbar{display:grid;grid-template-columns:1fr auto auto;gap:10px;align-items:end}.report-toolbar label{display:grid;gap:5px;color:var(--muted);font-size:9px}.report-toolbar select{background:#091517;border:1px solid var(--line);color:var(--ink);padding:9px}.report-toolbar div{display:flex}.report-toolbar button,.report-toolbar a{border:1px solid var(--line);background:#0b181a;color:var(--muted);padding:9px 11px;font-size:9px;cursor:pointer}.report-toolbar button.active{border-color:var(--cyan);color:var(--cyan)}.report-meta{display:flex;gap:10px;color:var(--muted);font-size:9px}.report-meta span{border:1px solid var(--line);padding:5px 8px}.summary-chart{display:grid;gap:6px}.summary-chart button{display:grid;grid-template-columns:150px 1fr 105px 120px;align-items:center;gap:10px;border:1px solid var(--line);background:#0b181a;color:var(--ink);padding:9px;text-align:left;cursor:pointer}.summary-chart button>span{font-size:10px}.summary-chart i{height:8px;background:#172b2f}.summary-chart i b{display:block;height:100%;background:linear-gradient(90deg,var(--cyan),var(--green))}.summary-chart strong{font:10px var(--font-geist-mono);color:var(--amber)}.summary-chart small{font-size:8px;color:var(--muted)}.seed-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;max-height:180px;overflow:auto}.seed-grid button{display:grid;text-align:left;gap:3px;border:1px solid var(--line);background:#0b181a;color:var(--ink);padding:8px;cursor:pointer}.seed-grid button.active{border-color:var(--cyan);background:#11272a}.seed-grid button.failed{border-color:#693b37}.seed-grid strong{font-size:10px}.seed-grid span,.seed-grid small{font-size:8px;color:var(--muted)}.run-detail{border:1px solid #38565b;background:#0c1a1d;padding:12px}.run-detail header{display:flex;justify-content:space-between;align-items:center}.run-detail h3{margin:4px 0}.run-detail header span{font-size:9px;color:var(--green)}.run-detail>div{display:grid;grid-template-columns:repeat(6,1fr);gap:6px}.run-detail p{display:grid;color:var(--muted);font-size:8px;background:#091416;padding:7px}.run-detail p strong{color:var(--ink);font-size:11px;margin-top:4px}.run-detail>button{border:1px solid var(--cyan);background:#11282a;color:var(--cyan);padding:9px 12px;cursor:pointer}.failure-panel{border-top:1px solid var(--line);padding-top:10px}.failure-panel h3{font-size:11px}.failure-panel article{display:grid;grid-template-columns:180px 100px 1fr;gap:8px;font-size:9px;color:var(--muted);padding:7px;background:#191313;border-bottom:1px solid #472a27}.failure-panel article p{margin:0}.failure-panel>p{color:var(--muted);font-size:9px} @media(max-width:1250px){.dashboard-grid{grid-template-columns:270px 1fr 310px}.metric-card strong{font-size:20px}} diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts index 85b408e..ffc99ba 100644 --- a/apps/web/app/lib/api.ts +++ b/apps/web/app/lib/api.ts @@ -3,6 +3,8 @@ export const API_BASE = process.env.NEXT_PUBLIC_INSIDEGOV_API_URL ?? "http://loc export type Offer = { subsidy: number; equity: number; + external_equity: number; + fund_allocations: Record; land_discount: number; credit_support: number; }; @@ -40,6 +42,16 @@ export type World = { phase: string; cities: Record; firms: Record; + investment_funds: Record; + promises: Array<{ + id: string; item: string; amount: number; due_quarter: number; condition: string; + status: string; paid_amount: number; delayed_quarters: number; + funding_source_id: string | null; + }>; events: Array<{ quarter: number; kind: string; title: string; detail: string; severity: string }>; traces: Array<{ id: string; actor_id: string; action: string; evidence: string[]; @@ -55,7 +67,7 @@ export type World = { proposal_tools: Record; finance_tool_limits: Record; final_tools: Record; - payment_schedule: Array<{ item: string; amount: number; due_offset: number; condition: string }>; + payment_schedule: Array<{ item: string; amount: number; due_offset: number; condition: string; funding_source_id?: string | null }>; turns: Array<{ actor_id: string; act: string; summary: string; amount?: number; approved?: boolean }>; }>; external_negotiations: Array<{ @@ -126,6 +138,75 @@ export type CandidateWorld = { }>; }; +export type DemoBundle = { + schema_version: string; generated_at: string; source: string; seed: number; + common_ancestor_quarter: number; + intervention: { quarter: number; target: string; operation: string; value: number }; + baseline: World; branch: World; + comparison: { baseline_world_id: string; branch_world_id: string; delta: Record }; + replay: { + selected_city_id: string; selected_city_name: string; + external_negotiation: World["external_negotiations"][number]; + internal_negotiation: World["negotiations"][number]; + synthetic_private_audit: Array<{ + audit_id: string; agent_id: string; private_context_used: Record; + observation: Record; llm_suggestion: Record; + rule_adjustment: Record; executed_action: Record; + rationale: string; reflection: string; provider: string; fallback: boolean; + }>; + funding_partners: Array; + promises: World["promises"]; + baseline_outcome: Metric; branch_outcome: Metric; + causal_events: World["events"]; + steps: Array<{ id: string; title: string; quarter: number }>; + }; + sensitivity: HefeiSensitivity; +}; + +export type HefeiSensitivity = { + case: string; seed: number; quarters: number; historical_equity_investment: number; + budget_shares: number[]; interpretation_boundary: string; + runs: Array<{ + available_budget_share: number; joint_investment: boolean; selected_city: string | null; + municipal_equity: number; external_equity: number; total_equity_support: number; + historical_equity_gap: number; fulfilled_promises: number; cluster_size: number; + total_employment: number; average_credibility: number; + }>; +}; + +export type ExperimentReportSummary = { + id: string; title: string; kind: string; source: string; generated_at: string | null; + strategy_runs: number; ablation_runs: number; failures: number; + drilldown_available: boolean; +}; + +export type ExperimentRun = { + strategy: string; seed: number; successful: boolean; selected_city: string | null; + fulfilled_promises: number; total_employment: number; total_committed_expenditure: number; + average_credibility: number; cluster_size: number; utilization: number; + world_id: string; world_available: boolean; +}; + +export type ExperimentMetricSummary = { + id?: string; name?: string; strategy?: string; mechanism?: string; + attempted?: number; successful?: number; success_rate?: number; + metrics?: Record; + employment?: number; employment_variance?: number; cluster?: number; + fulfilled_promises?: number; seeds?: number; +}; + +export type ExperimentReport = { + report_id: string; generated_at?: string; + report_summary: Partial; + configuration: Record; + strategy_summary: ExperimentMetricSummary[]; + ablation_summary: ExperimentMetricSummary[]; + strategy_runs: ExperimentRun[]; + ablation_runs: ExperimentRun[]; + failure_cases: Array<{ strategy: string; seed: number; stage: string; reason: string }>; + llm_quality?: Array>; +}; + async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...init, @@ -186,4 +267,11 @@ export const api = { seed: 42, }), }), + createDemo: (seed = 42, fiscalMultiplier = 0.5) => request("/demos/hefei-nio", { + method: "POST", body: JSON.stringify({ seed, fiscal_multiplier: fiscalMultiplier }), + }), + hefeiSensitivity: (seed = 42) => request(`/cases/hefei-nio/sensitivity?seed=${seed}`), + listExperimentReports: () => request("/experiment-reports"), + getExperimentReport: (id: string) => request(`/experiment-reports/${id}`), + getExperimentWorld: (reportId: string, worldId: string) => request(`/experiment-reports/${reportId}/worlds/${worldId}`), }; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 0c45d6c..287de13 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { DemoReplay } from "./components/DemoReplay"; +import { ReportCenter } from "./components/ReportCenter"; import { API_BASE, api, type AgentActionAudit, type CandidateWorld, type Capability, type InterventionPlan, type Interview, type Metric, type World } from "./lib/api"; const phaseNames: Record = { @@ -17,7 +19,7 @@ export default function Home() { const [error, setError] = useState(""); const [policyMode, setPolicyMode] = useState("deterministic"); const [model, setModel] = useState("deepseek-v4-flash"); - const [detail, setDetail] = useState<"negotiations" | "unified" | "trace" | "audit" | "counterfactual" | "interview" | "material" | "about" | null>(null); + const [detail, setDetail] = useState<"demo" | "reports" | "negotiations" | "unified" | "trace" | "audit" | "counterfactual" | "interview" | "material" | "about" | null>(null); const remember = useCallback((next: World) => { setWorld(next); @@ -72,22 +74,6 @@ export default function Home() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, world?.quarter]); - async function createBranch() { - if (!world) return; - setBusy(true); - try { remember(await api.branch(world.id)); await refreshList(); } - finally { setBusy(false); } - } - - async function intervene(kind: string, target: string, value: number) { - if (!world) return; - setBusy(true); - try { - await api.intervene(world.id, kind, target, value); - remember(await api.step(world.id)); - } finally { setBusy(false); } - } - const latest = world?.history.at(-1); const cities = world ? Object.values(world.cities) : []; const selected = world?.selected_city_id ? world.cities[world.selected_city_id] : null; @@ -102,10 +88,16 @@ export default function Home() {
内
InsideGov政企互动推演场
{world.name}{world.id}
- +
{error &&
{error}
} +
+
COMPETITION DEMO · RULE-GENERATED从真实案例到反事实结果,六步验证博弈不是预写剧情合肥—蔚来公开参数卡 · 联合产业基金 · Agent 私有信息 · 跨期履约
+ + +
+
模拟时钟2030 Q{((world.quarter || 1) - 1) % 4 + 1}第 {world.quarter} / 16 季度
{["招商竞争", "承诺履约", "产业演化"].map((item, index) =>
{index + 1}{item}
)}
@@ -146,16 +138,16 @@ export default function Home() {
DECISION TRACE

可追溯决策链

{world.traces.slice(-4).reverse().map((trace, index) =>
0{index + 1}
{trace.actor_id}

{trace.action}

调用证据

{trace.evidence.join(" · ")}

内部权衡

{trace.constraints.join(" · ")}

{Object.values(trace.expected_effects)[0]?.toFixed?.(1) ?? "—"}
)}
-
InsideGov v0.3 · 合成世界,不构成现实政策预测{world.policy_mode === "llm" ? world.model_name : "确定性基线"} · Seed {world.seed} · 自动持久化
+
InsideGov v0.5 · 政策实验与机制探索,不构成现实政策预测{world.policy_mode === "llm" ? world.model_name : "确定性基线"} · Seed {world.seed} · 自动持久化
- {detail &&
{detail === "about" ? : detail === "negotiations" ? : detail === "unified" ? : detail === "audit" ? : detail === "counterfactual" ? : detail === "interview" ? : detail === "material" ? : }
} + {detail &&
{detail === "demo" ? : detail === "reports" ? : detail === "about" ? : detail === "negotiations" ? : detail === "unified" ? : detail === "audit" ? : detail === "counterfactual" ? : detail === "interview" ? : detail === "material" ? : }
} ); } @@ -168,8 +160,8 @@ function ChartBar({ point }: { point: Metric }) { return ; } -function Negotiations({ world }: { world: World }) { return <>INTERNAL GOVERNANCE

招商局提案 → 财政局否决 → 市领导协调

{world.negotiations.slice().reverse().map((item) =>
Q{item.quarter} · {world.cities[item.city_id].name}招商局:现金 {money(item.proposal_tools?.subsidy)},股权 {money(item.proposal_tools?.equity)}财政局:现金上限 {money(item.finance_tool_limits?.subsidy)},股权上限 {money(item.finance_tool_limits?.equity)}市领导:现金 {money(item.final_tools?.subsidy)} + 股权 {money(item.final_tools?.equity)}

{item.concerns.join(";")}

{item.payment_schedule?.map((row, index) => Q+{row.due_offset} · {toolName(row.item)} {row.amount.toFixed(2)} 亿 · {row.condition})}
)}
; } -function UnifiedNegotiations({ world }: { world: World }) { const internal = Object.fromEntries(world.negotiations.map((item) => [item.id, item])); return <>NESTED NEGOTIATION

企业表达 → 政府澄清 → 内部会商 → 企业回应

{world.external_negotiations.slice().reverse().map((item) => { const meeting = internal[item.internal_negotiation_id]; return
Q{item.quarter} · {world.cities[item.city_id].name} · {item.enterprise_response}

企业表面诉求:{item.stated_need}

政府追问:{item.government_questions.join("、") || "沿用已有理解"}

理解更新:{brief(item.belief_before)} → {brief(item.belief_after)}(信心 {(item.belief_confidence * 100).toFixed(0)}%)

{meeting && <>招商局:现金 {money(meeting.proposal_tools.subsidy)}+股权 {money(meeting.proposal_tools.equity)}财政硬上限:现金 {money(meeting.finance_tool_limits.subsidy)}+股权 {money(meeting.finance_tool_limits.equity)}领导协调:现金 {money(meeting.final_tools.subsidy)}+股权 {money(meeting.final_tools.equity)}}

企业回应:{item.enterprise_rationale}

{Object.keys(item.counter_terms).length > 0 &&

还价条件:{brief(item.counter_terms)}

}效用 {item.utility.toFixed(1)} / 最低门槛 {item.minimum_utility.toFixed(1)} · 内部记录 {item.internal_negotiation_id}
; })}
; } +function Negotiations({ world }: { world: World }) { return <>INTERNAL GOVERNANCE

招商局提案 → 产业基金审核 → 财政局否决 → 市领导协调

{world.negotiations.slice().reverse().map((item) =>
Q{item.quarter} · {world.cities[item.city_id].name}招商局:现金 {money(item.proposal_tools?.subsidy)},本级股权 {money(item.proposal_tools?.equity)}联合基金:独立审核 {money(item.proposal_tools?.external_equity)}财政局:现金上限 {money(item.finance_tool_limits?.subsidy)},本级股权上限 {money(item.finance_tool_limits?.equity)}最终:现金 {money(item.final_tools?.subsidy)} + 总股权 {money(item.final_tools?.total_equity_support)}

{item.concerns.join(";")}

{item.payment_schedule?.map((row, index) => Q+{row.due_offset} · {toolName(row.item)} {row.amount.toFixed(2)} 亿 · {row.condition})}
)}
; } +function UnifiedNegotiations({ world }: { world: World }) { const internal = Object.fromEntries(world.negotiations.map((item) => [item.id, item])); return <>NESTED NEGOTIATION

企业表达 → 政府澄清 → 内部会商 → 企业回应

{world.external_negotiations.slice().reverse().map((item) => { const meeting = internal[item.internal_negotiation_id]; return
Q{item.quarter} · {world.cities[item.city_id].name} · {item.enterprise_response}

企业表面诉求:{item.stated_need}

政府追问:{item.government_questions.join("、") || "沿用已有理解"}

理解更新:{brief(item.belief_before)} → {brief(item.belief_after)}(信心 {(item.belief_confidence * 100).toFixed(0)}%)

{meeting && <>招商局:现金 {money(meeting.proposal_tools.subsidy)}+本级股权 {money(meeting.proposal_tools.equity)}联合基金:{money(meeting.proposal_tools.external_equity)}财政硬上限:现金 {money(meeting.finance_tool_limits.subsidy)}+本级股权 {money(meeting.finance_tool_limits.equity)}领导协调:现金 {money(meeting.final_tools.subsidy)}+总股权 {money(meeting.final_tools.total_equity_support)}}

企业回应:{item.enterprise_rationale}

{Object.keys(item.counter_terms).length > 0 &&

还价条件:{brief(item.counter_terms)}

}效用 {item.utility.toFixed(1)} / 最低门槛 {item.minimum_utility.toFixed(1)} · 内部记录 {item.internal_negotiation_id}
; })}
; } function Traces({ world }: { world: World }) { return <>AUDIT LOG

完整决策链

{world.traces.slice().reverse().map((trace) =>
{trace.actor_id} · {trace.action}

{trace.evidence.join(";")}

{trace.outcome}
)}
; } function AuditTimeline({ world }: { world: World }) { const [quarter, setQuarter] = useState("all"); @@ -180,7 +172,7 @@ function AuditTimeline({ world }: { world: World }) { const visible = audits.filter((item) => (quarter === "all" || item.quarter === quarter) && (agent === "all" || item.agent_id === agent)); return <>P2.1 · AUDITABLE AGENTS

观察 → 建议 → 规则修正 → 执行 → 复盘

{visible.slice().reverse().map((item) => )}{!visible.length &&

当前筛选条件下暂无认知行动。

}
; } -function AuditCard({ item }: { item: AgentActionAudit }) { return
Q{item.quarter} · {item.agent_id} · {item.action_type}{item.provider}{item.fallback ? " · 已降级" : " · 原生执行"}

当时观察:{brief(item.observation)}

私有信息:{"redacted" in item.private_context_used ? `默认脱敏;使用字段 ${(item.private_context_used.fields_used ?? []).join("、")}` : "已记录"}

模型/策略建议:{brief(item.llm_suggestion)}

规则修正:{brief(item.rule_adjustment)}

最终执行:{brief(item.executed_action)}

理由:{item.rationale}

复盘:{item.reflection}

诊断记录 {item.diagnostics.length} 条 · {item.outcome}
; } +function AuditCard({ item }: { item: AgentActionAudit }) { const fields = item.private_context_used.fields_used; const fieldNames = Array.isArray(fields) ? fields.join("、") : ""; return
Q{item.quarter} · {item.agent_id} · {item.action_type}{item.provider}{item.fallback ? " · 已降级" : " · 原生执行"}

当时观察:{brief(item.observation)}

私有信息:{"redacted" in item.private_context_used ? `默认脱敏;使用字段 ${fieldNames}` : "已记录"}

模型/策略建议:{brief(item.llm_suggestion)}

规则修正:{brief(item.rule_adjustment)}

最终执行:{brief(item.executed_action)}

理由:{item.rationale}

复盘:{item.reflection}

诊断记录 {item.diagnostics.length} 条 · {item.outcome}
; } function brief(value: unknown) { const text = JSON.stringify(value); return text.length > 240 ? `${text.slice(0, 237)}…` : text; } function CounterfactualLab({ world, remember, refreshList }: { world: World; remember: (world: World) => void; refreshList: () => Promise }) { const [quarter, setQuarter] = useState(Math.max(0, world.quarter - 1)); @@ -199,4 +191,4 @@ function CounterfactualLab({ world, remember, refreshList }: { world: World; rem function InterviewLab({ world }: { world: World }) { const agents = Object.keys(world.action_audits.reduce((acc, item) => ({ ...acc, [item.agent_id]: true }), {} as Record)); const [agent, setAgent] = useState(agents[0] ?? "city_lin_finance"); const [quarter, setQuarter] = useState(Math.min(1, world.quarter)); const [question, setQuestion] = useState("为什么否决现金加码?"); const [result, setResult] = useState(null); return <>P2.3 · GROUNDED INTERVIEW

采访指定历史节点的 Agent