diff --git a/CHANGELOG.md b/CHANGELOG.md
index af19aa5..35cc5ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## v4.16.0 - 2026-07-23
+
+- `--market research` 的公司与基金路径新增确定性命题发布层。每位 lens 分离 `publishable_claims` 与 `unpublished_questions`,committee 只综合达到离散证据门槛的命题;普通缺口不再被解释为看空、中性、保守或观望信号。
+- 发布命题必须引用冻结快照内的 `evidence_id`,并记录适用期、条件和失效条件;Workspace 新增 `evidence_manifest.json`、`claim_ledger.json`、`coverage_report.json` 与 `unpublished_claims.json`。
+- 价格、总市值或流动性缺失改为只阻断估值/执行行动;身份、口径完整性、一手冲突、前视偏差或完全没有可发布命题时阻断整份 research 报告。其他报告入口保持原有缺口展示契约。
+- 全面更新中英文 README 的产品定位、入口说明、架构和命题发布流程;更新双语架构 GIF、视频封面与 72 秒 Remotion 演示。
+
## v4.15.0 - 2026-07-20
相较 v4.14.0,本版本把原有 A/HK/US 市场与 A 股公司/基金研究框架扩展为可审计的 A/HK/US/JP/KR 全球证据架构,并集中修复盘前时段、跨市场财务、基金运营、组合风险和 Agent 一手证据回填中的真实缺口。
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index de0968a..1e87fe6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,6 +36,8 @@ If a public source is unavailable from your network, keep the failure visible. D
- Prices less than or equal to zero must be filtered or marked invalid.
- Every strong market conclusion should trace back to evidence: quote, turnover, breadth, sector rotation, flow, announcement, or risk event.
- Source, URL, time, and fallback reason belong in evidence metadata, not hidden in prose.
+- In company/fund `research`, ordinary missing evidence filters or narrows the affected claim and is retained in audit artifacts; it must not become a bearish, neutral, conservative, or wait-and-see signal.
+- Daily, review, earnings, price-move, snapshot, and portfolio workflows keep their existing visible missing-module contracts.
- Reports are research outputs, not investment advice.
## Pull Request Shape
@@ -69,9 +71,9 @@ Investor lenses are not roleplay prompts. A lens must define:
- Evidence priority.
- Risk vocabulary.
-- What counts as insufficient evidence.
+- What meets the discrete claim-publication rules.
- How it changes the report structure.
-- Which data gaps must be called out.
+- Which data gaps narrow a research claim, block a claim, block an action, or block a report.
Do not add a lens that mainly changes tone.
diff --git a/README.md b/README.md
index c1e7dba..04c2270 100644
--- a/README.md
+++ b/README.md
@@ -5,16 +5,24 @@
简体中文
+
+
+
+
+
+
+
+
- Build the operating model. Reverse-engineer the price. Find the expectation gap.
+ Evidence-first investment research for agents and humans.
- A/HK/US/JP/KR stocks · Forward + reverse valuation · SOTP · ETFs / funds · Dynamic committee · Primary disclosures
+ Verify the premise · Model the business · Reverse the price · Publish only supported claims
@@ -25,24 +33,14 @@
- Listed in thuquant/awesome-quant via merged PR
- #48 .
-
-
-
- Listed in leoncuhk/awesome-quant-ai
- under Tools and Platforms / Data Providers via merged PR
- #39 .
+ A/HK/US/JP/KR stocks · ETFs and funds · Portfolios · Primary disclosures · Forward/reverse valuation · Dynamic committee
- Listed in wangzhe3224/awesome-systematic-trading via merged PR
- #124 .
-
-
-
- Listed in 0xNyk/awesome-hermes-agent via merged PR
- #232 .
+ Recognition: awesome-quant #48 ·
+ awesome-quant-ai #39 ·
+ awesome-systematic-trading #124 ·
+ awesome-hermes-agent #232
Investors rarely struggle because they cannot generate another paragraph of commentary. The harder questions are more concrete:
@@ -52,7 +50,16 @@ Investors rarely struggle because they cannot generate another paragraph of comm
- Did the latest filing improve earnings quality, cash conversion, and shareholder returns?
- Does a ten-position portfolio actually contain ten independent risks?
-`stock-analysis` handles the hard middle of investment research: verify the premise, reconcile conflicting evidence, build the operating case, and reverse the current market cap into the earnings it already discounts. The output shows the gap between your model and the market's model—not a one-line Buy/Sell label.
+`stock-analysis` is an open-source, deterministic research operating system for global stocks, funds, and portfolios. It handles the hard middle of investment research: verify the premise, reconcile conflicting evidence, build the operating case, reverse the current market cap into the earnings it already discounts, and publish only claims that pass explicit evidence rules. The output shows the gap between your model and the market's model—not a one-line Buy/Sell label.
+
+### Choose your path
+
+| You are… | Start here | What you get |
+|---|---|---|
+| An investor using an Agent | [Install the Agent entrypoints](#agent-installation), then ask in plain language | The Agent selects the matching deterministic workflow and explains the resulting evidence |
+| A CLI user | `uv tool install stock-analysis` | Reproducible Markdown and JSON from stable commands, with no LLM required |
+| A researcher or reviewer | `stock-analysis --market research --symbol ` | A recoverable Workspace, frozen evidence, claim ledger, committee review, and final report |
+| A contributor | [Development](#development) | Tests, schemas, canonical Agent contracts, and extensible provider/lens boundaries |
After installing the Skill, ask your Agent in plain language:
@@ -80,10 +87,23 @@ stock-analysis --market research --symbol 600519 --expectations-file examples/co
## 72-second demo
-- [English video](promo/demo-video/out/stock-analysis-demo-en.mp4)
-- [简体中文视频](promo/demo-video/out/stock-analysis-demo-zh-CN.mp4)
+
+
+
+
+
+Click either poster to play the [English video](promo/demo-video/out/stock-analysis-demo-en.mp4) or [简体中文视频](promo/demo-video/out/stock-analysis-demo-zh-CN.mp4).
-Both demos are 1080p, 72 seconds, caption-led, and work without audio. The v4.15 cut adds A/HK/US/JP/KR coverage, exchange-session semantics, SEC filing-date evidence, issuer-primary reach, local-currency liquidity, portfolio correlation, and FX attribution while retaining forward/reverse valuation and the dynamic committee. Editable Remotion source lives in [`promo/demo-video`](promo/demo-video/).
+Both demos are 1080p, 72 seconds, caption-led, and work without audio. The v4.16 cut follows the full path from multi-market evidence through forward/reverse valuation, discrete claim validation, publication gating, audit artifacts, and the dynamic committee. Editable Remotion source lives in [`promo/demo-video`](promo/demo-video/).
+
+## Read this README by goal
+
+- [Choose the right research entrypoint](#start-with-the-investor-question)
+- [Understand the architecture](#how-the-system-works)
+- [Install for an Agent or the terminal](#agent-installation)
+- [Copy a working prompt](#prompt-cookbook-after-installation)
+- [Run the CLI](#quickstart)
+- [Audit the evidence and claim contracts](#evidence-modules)
## Why It Exists
@@ -136,11 +156,11 @@ The animated overview follows the investor journey: ask a question, gather check
```mermaid
flowchart TB
U["Your investment question\nasset · date · decision tension"] --> I["Intent routing\nmarket · company · ETF/fund · portfolio · earnings · price move"]
- I --> P["Premise audit + evidence arbitration\nperiod · scope · source tier · conflicts"]
- P --> C1["Market evidence\nA/HK/US/JP/KR quotes · sessions · calendars · breadth"]
- P --> C2["Structured company evidence\nA-share disclosures · SEC filed XBRL · conditional global financials"]
- P --> C3["Fund, index, and portfolio\nholdings · weights · valuation · liquidity · correlation · FX"]
- P --> C4["Primary-evidence reach\nissuer IR · exchange · regulator · publication cutoff"]
+ I --> PA["Premise audit + evidence arbitration\nperiod · scope · source tier · conflicts"]
+ PA --> C1["Market evidence\nA/HK/US/JP/KR quotes · sessions · calendars · breadth"]
+ PA --> C2["Structured company evidence\nA-share disclosures · SEC filed XBRL · conditional global financials"]
+ PA --> C3["Fund, index, and portfolio\nholdings · weights · valuation · liquidity · correlation · FX"]
+ PA --> C4["Primary-evidence reach\nissuer IR · exchange · regulator · publication cutoff"]
C1 --> V["Validated research base\nM1–M6 · C1–C8 · F1–F8 · Portfolio"]
C2 --> V
C3 --> V
@@ -149,10 +169,14 @@ flowchart TB
V --> B["Reverse model\nmarket cap ÷ multiple · implied profit · residual option value"]
F --> G["Expectation-gap bridge\nmodel vs price · feasibility · no double counting"]
B --> G
- G --> K["Dynamic committee\nsix relevant frameworks selected from fifteen"]
- K --> S["Committee synthesis\nconsensus · disagreement · vetoes · conditional actions"]
+ G --> K["Framework analysis\nsix relevant lenses selected from fifteen"]
+ K --> X["Claim compiler\nextract · cite · validate · narrow scope"]
+ X --> PC["Publishable claims\nstrongly_supported · supported"]
+ X --> A["Audit-only claims\nunsupported · speculative · unresolved conflict"]
+ PC --> S["Committee synthesis\nconsensus · disagreement · vetoes · conditional actions"]
S --> R["Investor report\nwhat is priced in · variant view · triggers · watchlist"]
S --> W["Recoverable workspace\nstage outputs · compare the next review"]
+ A --> W
```
For an investor, this reduces to four steps:
@@ -160,7 +184,7 @@ For an investor, this reduces to four steps:
1. State the asset and the decision question in normal language.
2. The system chooses the appropriate evidence path and blocks information published after the research date.
3. Six relevant frameworks are selected from fifteen; the committee is not a fixed cast repeating generic views.
-4. For companies, the report reconciles the forward model with market-implied earnings before showing disagreements, invalidation conditions, and what to monitor next.
+4. For company and fund research, only supported claims reach committee synthesis; unpublished claims and coverage gaps remain in machine-readable audit artifacts.
The essential boundary is deliberate: **the question selects the research path, code obtains and validates evidence, and an investment framework only interprets existing data.** M1–M6 describes markets and portfolios, C1–C8 describes companies, and F1–F8 describes fund contracts, index exposure, valuation, tracking, and implementation.
@@ -174,6 +198,39 @@ flowchart LR
V2 -- No --> G["Keep a visible research boundary\nnever fill zero or guess"]
```
+### Supported-Claim Publication for deep research
+
+The v4.16 publication layer is deliberately scoped: it is enabled by default only for company and fund `--market research`. Daily recaps, `stock-review`, `earnings`, `price-move`, snapshots, and portfolio reports keep their existing contracts, including visible missing-module explanations where those workflows require them.
+
+```mermaid
+flowchart TB
+ E["Frozen evidence\nsource · date · period · scope"] --> X["Extract structured claims\nstatement · evidence_ids · conditions · invalidators"]
+ X --> V{"Discrete support rules"}
+ V -->|strongly_supported / supported| P["Publishable claim"]
+ V -->|unsupported / speculative / conflicted_unresolved| U["Audit only"]
+ P --> M{"Missing-evidence effect"}
+ M -->|no_material_effect| R["Publish as written"]
+ M -->|narrows_scope| N["Publish the narrower supported claim"]
+ M -->|blocks_action| B["Publish research; block valuation or execution action"]
+ M -->|blocks_claim| U
+ R --> C["Committee + existing eight-section report"]
+ N --> C
+ B --> C
+ U --> A["claim_ledger.json\nunpublished_claims.json"]
+ C --> O["Investor-facing Markdown"]
+```
+
+There is no continuous confidence score. A claim uses exactly one of five states: `strongly_supported`, `supported`, `unsupported`, `speculative`, or `conflicted_unresolved`. Ordinary missing evidence filters or narrows the affected claim; it does not become bearish, neutral, conservative, or wait-and-see evidence.
+
+| Integrity condition | Publication result |
+|---|---|
+| Missing current price | Block current valuation and price-dependent action; keep supported business research |
+| Missing market cap | Block reverse valuation; keep supported fundamental research |
+| Missing liquidity | Block execution planning; keep supported research conclusions |
+| Unresolved identity, reporting-basis, primary-source conflict, look-ahead, or no relevant publishable claim | Block the research report |
+
+The public report keeps the existing company/fund eight-section skeleton and never derives automatic position sizes or unconditional buy/sell instructions.
+
## Agent installation
### No programming experience: give this to your Agent
@@ -196,7 +253,7 @@ After that, ask “deeply research 600519”, “recap today's A-share market”
```bash
git clone https://github.com/AdvancingTitans/stock-analysis.git
cd stock-analysis
-uv tool install stock-analysis
+uv tool install --force .
python3 scripts/sync_agent_entrypoints.py --check
scripts/install-agent-entrypoints.sh codex
scripts/install-agent-entrypoints.sh claude
@@ -208,30 +265,48 @@ For CLI-only use, `uv tool install stock-analysis` is enough. The Agent installe
A useful prompt only needs four things: **asset, research date, core question, and the decision you are trying to make.**
+### 1. Daily market recap
+
```text
Recap today's A-share close. Assess indices, breadth, sector rotation, and risk appetite,
then explain which of my holdings beat or lagged their benchmarks and build tomorrow's watchlist.
Do not turn missing data into zero and do not merely repeat headlines.
```
+### 2. Company deep research
+
```text
Deeply research Kweichow Moutai 600519. Test whether the current valuation is supported by
three-year earnings, cash generation, shareholder returns, and capital allocation.
Select the six most relevant frameworks and produce an investment-committee report.
```
+### 3. ETF / fund research
+
```text
Research semiconductor ETF 512480. Go beyond past returns and top holdings: verify the full
underlying index, weights, valuation, and daily history; recompute tracking error, drawdown,
and volatility; estimate round-trip costs for CNY 100k, 1m, and 5m orders.
```
+### 4. Earnings review
+
+```text
+Review the latest 600519 results. Compare revenue, profit, gross margin, ROE, operating cash
+flow, free cash flow, dividends, and capital expenditure with the prior reporting period.
+Separate disclosed facts, reproducible inferences, and operating questions that remain unresolved.
+```
+
+### 5. Portfolio diagnosis
+
```text
Review my holdings: 100 shares of 600519 bought on 2026-06-01 and 100,000 units of 512480
bought on 2026-05-20. Diagnose sector, style, market, and currency concentration; compare
benchmarks and state the conditions for holding, reducing risk, or waiting for more evidence.
```
+### 6. One framework or an adversarial comparison
+
```text
Use Buffett mode to analyze Tencent, focusing on business quality, capital allocation,
and long-term cash flow.
@@ -245,7 +320,7 @@ Let the portfolio manager synthesize the decision.
## Report Showcase
-Start with the two current deep-research reports. They show the difference between this workflow and a generic AI write-up:
+These representative reports demonstrate the deterministic evidence and committee workflow. The v4.16 claim-publication contract is specified above and in the Workspace audit artifacts.
| Scenario | Question answered in the report | Example |
|---|---|---|
@@ -275,6 +350,7 @@ Browse [reports/](reports/) for more reports, screenshots, and automation exampl
| A/HK/US/JP/KR stocks, funds, and portfolios | One entry layer for recaps, stocks, funds, earnings, price moves, screens, portfolios, and investment theses. |
| Multi-source and as-of discipline | Stable public sources first, validated fallback when needed, and no future disclosures in historical research. |
| Recoverable research workspace | Saves the plan, research base, framework opinions, committee synthesis, and final report for later comparison. |
+| Supported-claim publication | Company/fund research publishes only cited, condition-bearing claims; rejected claims and gaps remain in four fixed JSON audit artifacts. |
| Human- and machine-readable output | Markdown for investors; JSON Evidence Packs for Agent verification, automation, and reuse. |
## Quickstart
@@ -366,6 +442,21 @@ Company research has a different data boundary from daily market recap. `company
A/HK/US/JP/KR daily histories now expose 20-day average local-currency turnover and 60-day volatility. Portfolio evidence computes pairwise return correlations and daily local-price/FX/CNY-return attribution when histories align. Live valuation FX never falls back to hard-coded rates. Historical order books, broker-specific commissions, and true fund creations/redemptions remain explicit gaps.
+### Fund Evidence Pack (F1–F8)
+
+Fund research evaluates both the listed product and what it actually owns. The module names are stable across the Workspace and claim ledger.
+
+| Module | Investor question | Evidence focus |
+|---|---|---|
+| F1 Product and index contract | What must the fund track, and under what rules? | Fund profile, tracked index, replication method, constituent and rebalance rules |
+| F2 Exposure and concentration | What does the portfolio actually own? | Complete constituents or disclosed holdings, weights, top-5/top-10 concentration |
+| F3 Performance and trend | What has the product delivered? | Listed-price and profile returns with explicit periods |
+| F4 Tracking, premium, and implementation | Does the listed product follow NAV/index efficiently? | Premium/discount, daily-aligned tracking statistics, implementation evidence |
+| F5 Underlying valuation | What valuation does the index or covered holdings imply? | Official index valuation first; conditional holding-level proxies remain labelled |
+| F6 Risk budget and drawdown | How volatile and concentrated is the exposure? | Drawdown, volatility, beta, history sample size, concentration proxies |
+| F7 Governance, scale, and operations | Can it be held and traded as intended? | Fees, AUM, manager context, subscriptions/redemptions, liquidity and cost scenarios |
+| F8 Catalysts and monitoring | What observable event changes the case? | Rebalances, disclosure refreshes, metrics, dates, conditions, and invalidators |
+
### Forward model + reverse price validation
Pass an explicit JSON assumption set when the research question requires product-line economics, SOTP, or option value:
@@ -383,7 +474,23 @@ The double-count guard is deliberate: a product line listed under `includes_prod
`stock-analysis --market research --symbol ` materializes an institutional workflow under `~/.stock_analysis/research///` (override it with `STOCK_ANALYSIS_RESEARCH_DIR` or `--workspace-dir`). Company research freezes C1–C8 Company Evidence; fund research (`--asset-type fund`, or common listed-fund prefixes in auto mode) freezes a separate F1–F8 Fund Evidence model covering mandate, holdings concentration, performance, tracking/premium, underlying valuation gaps, risk, governance, and monitoring triggers. Every lens and committee consumes the same content-addressed snapshot. Re-running the same date preserves manually edited artifacts and writes refreshed output to a `.generated` sibling.
-Company opinions are deterministic framework assessments, not simulated quotations: every supporting and counter-evidence reference is an `evidence_id` from the same frozen `snapshot_id`. Committee synthesis rejects mixed snapshots, preserves consensus/disagreement and risk vetoes, and returns only `observe` or `manual_review`—never an automatic position or trade. Connected inputs now include structured financial disclosures/forecasts/flashes, PE/PB/market-cap snapshots, financing cash flow, and governance/capital-allocation announcement indexes. Aggregator records remain secondary until their linked issuer/exchange originals are verified.
+```text
+~/.stock_analysis/research///
+├── 01-research-plan.md
+├── 02-frozen-company-evidence.json # or 02-frozen-fund-evidence.json
+├── 03-evidence-summary.md
+├── 04-*-lens-opinions.json
+├── 05-committee-synthesis.json
+├── 06-decision-memo.md
+├── 07-institutional-report.md
+├── evidence_manifest.json
+├── claim_ledger.json
+├── coverage_report.json
+├── unpublished_claims.json
+└── workspace.json
+```
+
+Company opinions are deterministic framework assessments, not simulated quotations: every supporting and counter-evidence reference is an `evidence_id` from the same frozen `snapshot_id`. Committee synthesis rejects mixed snapshots and consumes only publishable claims relevant to the research question. `publication_status` is `publish`, `block_action`, or `block_report`; the workflow action remains `manual_review` unless report integrity requires `block_report`, and it never derives an automatic position or trade. Connected inputs now include structured financial disclosures/forecasts/flashes, PE/PB/market-cap snapshots, financing cash flow, and governance/capital-allocation announcement indexes. Aggregator records remain secondary until their linked issuer/exchange originals are verified.
Every financial fact records its period, currency, accounting scope, source type, source, and confidence so that you can trace a number back to its origin. The metric registry at [`config/metric_registry.json`](config/metric_registry.json) declares how a metric is validated and which framework can use it. It never produces a composite “buy score.”
@@ -451,10 +558,15 @@ See [examples/agent.md](examples/agent.md) for a daily agent workflow and [examp
| Board rankings | Eastmoney `clist` | Tonghuashun public pages → browser fallback |
| HK quotes | Tencent/Sina | Eastmoney `stock/get` |
| US quotes | Sina/Tencent | Eastmoney `searchapi` → `stock/get` |
+| JP/KR quotes and daily history | Yahoo chart route; KR cross-check via Naver | Explicit missing state when symbol/calendar validation fails |
+| US filed financials | SEC Company Facts with filing-date cutoff | Conditional global statements when scope and as-of checks pass |
+| Company primary evidence | Issuer IR → exchange → regulator | Targeted primary-evidence requests; unresolved gaps remain explicit |
+| CSI index composition and valuation | Official CSI files | Disclosed fund holdings with conditional coverage labels |
+| FX for portfolio valuation | Live public FX route | No hard-coded live-rate fallback |
| Funds | Eastmoney/Tiantian fund pages | Sina fund fallback |
| Deep tick/order-book data | Optional `mootdx` | Basic Tencent/Sina quotes |
-Yahoo is intentionally not part of the recommended default path.
+Yahoo is not the default route for A/HK/US quotes. It is used conditionally for the documented JP/KR history and global-statement paths, with symbol, date, scope, and completeness checks.
## Investor Lenses
@@ -466,9 +578,11 @@ Lenses change evidence priority and narrative structure. They do not override da
### Built-in Lens and Committee Boundaries
-Current CLI version: `4.15.0`.
+Current CLI version: `4.16.0`.
+
+`research` reports retain the denser Chinese committee narrative from the 4.5 series while keeping recoverable, traceable research state inside the Workspace. Company and fund reports preserve their institutional committee spines and use a deterministic claim-publication layer: each lens separates `publishable_claims` from `unpublished_questions`, and the committee consumes only the former. Ordinary missing evidence filters or narrows the affected claim; it is not treated as a bearish, neutral, conservative, or wait-and-see signal.
-`research` reports retain the denser Chinese committee narrative from the 4.5 series while keeping recoverable, traceable research state inside the Workspace. Company and fund reports preserve their institutional committee spines, but user-facing Markdown no longer exposes coverage flags, missing-module diagnostics, internal actions, snapshot IDs, or audit terminology.
+Every published claim cites frozen `evidence_id` values and carries an applicable period, conditions, and invalidators. Unsupported, speculative, and unresolved-conflict claims stay outside the investor-facing Markdown and are retained in `evidence_manifest.json`, `claim_ledger.json`, `coverage_report.json`, and `unpublished_claims.json`. Missing price, market-cap, or liquidity inputs block valuation or execution actions without suppressing supported business conclusions; identity, basis-integrity, primary-source conflict, look-ahead, and no-supported-claim failures block the report.
`LensEngine` is the report orchestration layer. For `research`, the user's question deterministically selects the six most relevant and complementary lenses from the 15 built-ins; an explicit expert selection overrides that choice. Every selected member consumes all structured metrics from the same research point before interpreting them through its own framework. Natural-language callers can still request single-lens or adversarial modes explicitly.
@@ -506,7 +620,7 @@ Start with [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
Use this one-liner when submitting the project to curated lists:
-> [stock-analysis](https://github.com/AdvancingTitans/stock-analysis) - Evidence-driven market recap CLI for AI agents and quant researchers, supporting A/HK/US/JP/KR stocks, funds, portfolios, auditable JSON Evidence Packs, data-quality scoring, investor lenses, and multi-source fallback routing.
+> [stock-analysis](https://github.com/AdvancingTitans/stock-analysis) - Evidence-first research CLI and Agent workflow for A/HK/US/JP/KR stocks, funds, and portfolios, with primary disclosures, forward/reverse valuation, recoverable Workspaces, supported-claim publication, and auditable JSON evidence contracts.
High-fit targets include `awesome-quant-ai`, `awesome-ai-in-finance`, `awesome-quant`, and `awesome-systematic-trading`.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index cf3ef89..b1e2240 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,16 +5,24 @@
简体中文
+
+
+
+
+
+
+
+
- 正向搭经营模型,逆向拆市场定价,找出真正的预期差。
+ 面向 Agent 与投资者的证据优先投研系统。
- A 股 / 港股 / 美股 / 日股 / 韩股 · 正向+逆向估值 · SOTP · ETF / 基金 · 动态投委会 · 一手披露
+ 核验前提 · 建模经营 · 逆向拆价 · 只发布证据支持的命题
@@ -25,24 +33,14 @@
- 已通过 PR #48 收录到
- thuquant/awesome-quant 。
-
-
-
- 已通过 PR #39 收录到
- leoncuhk/awesome-quant-ai 的
- Tools and Platforms / Data Providers 。
-
-
-
- 已通过 PR #124 收录到
- wangzhe3224/awesome-systematic-trading 。
+ A 股 / 港股 / 美股 / 日股 / 韩股 · ETF / 基金 · 组合 · 一手披露 · 正逆向估值 · 动态投委会
- 已通过合并的 PR #232 收录到
- 0xNyk/awesome-hermes-agent 。
+ 社区收录:awesome-quant #48 ·
+ awesome-quant-ai #39 ·
+ awesome-systematic-trading #124 ·
+ awesome-hermes-agent #232
如果你经常有这些疑问:
@@ -52,7 +50,16 @@
- 财报发布后,利润、现金流、分红和资本配置到底发生了什么?
- 我的持仓看起来有十只股票,实际是不是都押在同一个风格上?
-`stock-analysis` 希望补上投研最难的中间环节:先核验用户前提和冲突证据,再建立经营模型,并把当前市值逆推出市场已经交易的利润。最终交付不是一句“买入/卖出”,而是你的模型与市场模型之间究竟差在哪里。
+`stock-analysis` 是面向全球股票、基金与组合的开源确定性投研操作系统。它补上投研最难的中间环节:先核验用户前提和冲突证据,再建立经营模型,把当前市值逆推出市场已经交易的利润,并且只发布通过明确证据规则的命题。最终交付不是一句“买入/卖出”,而是你的模型与市场模型之间究竟差在哪里。
+
+### 按你的使用方式开始
+
+| 你是… | 从这里开始 | 你会得到什么 |
+|---|---|---|
+| 使用 Agent 的投资者 | [安装 Agent 入口](#agent-安装),然后直接用自然语言提问 | Agent 选择对应的确定性流程,并解释可核验结果 |
+| CLI 用户 | `uv tool install stock-analysis` | 无需 LLM,即可用稳定命令生成可复现的 Markdown 与 JSON |
+| 研究员或复核者 | `stock-analysis --market research --symbol <代码>` | 可恢复 Workspace、冻结证据、命题台账、投委会复核和最终报告 |
+| 贡献者 | [开发](#开发) | 测试、Schema、统一 Agent 契约,以及可扩展的数据源与 lens 边界 |
不懂 Python 也可以使用。安装 Skill 后,在 Codex、Claude Code 或 Hermes 里直接说中文即可:
@@ -80,10 +87,23 @@ stock-analysis --market research --symbol 600519 --expectations-file examples/co
## 72 秒演示
-- [简体中文视频](promo/demo-video/out/stock-analysis-demo-zh-CN.mp4)
-- [English video](promo/demo-video/out/stock-analysis-demo-en.mp4)
+
+
+
+
-两个演示均为 1080p、72 秒,以字幕传递完整信息,静音也能观看。v4.15 版新增 A/HK/US/JP/KR、交易时段语义、SEC filing-date 一手财务、发行人原文补齐、本币流动性、组合相关性和汇率归因,同时保留正逆向估值与动态投委会;可编辑的 Remotion 工程位于 [`promo/demo-video`](promo/demo-video/)。
+点击海报即可播放[简体中文视频](promo/demo-video/out/stock-analysis-demo-zh-CN.mp4)或 [English video](promo/demo-video/out/stock-analysis-demo-en.mp4)。
+
+两个演示均为 1080p、72 秒,以字幕传递完整信息,静音也能观看。v4.16 版完整展示多市场证据、正逆向估值、离散命题校验、发布门控、审计产物与动态投委会;可编辑的 Remotion 工程位于 [`promo/demo-video`](promo/demo-video/)。
+
+## 按目标阅读 README
+
+- [选择正确的研究入口](#从投资问题开始)
+- [理解系统架构](#系统如何工作)
+- [为 Agent 或终端安装](#agent-安装)
+- [复制可用的提示词](#安装后推荐这样提问)
+- [运行 CLI](#快速开始)
+- [审计证据与命题契约](#证据模块)
## 为什么需要它
@@ -136,11 +156,11 @@ Claude Code 原生支持 `/command` 入口。Codex 的 Custom Prompt 显示为 `
```mermaid
flowchart TB
U["你提出投资问题\n标的 · 时间 · 最关心的矛盾"] --> I["场景与意图识别\n市场 · 公司 · ETF/基金 · 持仓 · 财报 · 异动"]
- I --> P["前提审计与证据仲裁\n报告期 · 口径 · 来源层级 · 冲突"]
- P --> C1["市场证据\nA/HK/US/JP/KR 行情 · 时段 · 日历 · 广度"]
- P --> C2["结构化公司证据\nA股披露 · SEC filing XBRL · 条件性全球财务"]
- P --> C3["基金 指数与组合\n持仓 · 权重 · 估值 · 流动性 · 相关性 · 汇率"]
- P --> C4["一手证据补齐\n公司IR · 交易所 · 监管机构 · 发布日截止"]
+ I --> PA["前提审计与证据仲裁\n报告期 · 口径 · 来源层级 · 冲突"]
+ PA --> C1["市场证据\nA/HK/US/JP/KR 行情 · 时段 · 日历 · 广度"]
+ PA --> C2["结构化公司证据\nA股披露 · SEC filing XBRL · 条件性全球财务"]
+ PA --> C3["基金 指数与组合\n持仓 · 权重 · 估值 · 流动性 · 相关性 · 汇率"]
+ PA --> C4["一手证据补齐\n公司IR · 交易所 · 监管机构 · 发布日截止"]
C1 --> V["经校验的研究底稿\nM1–M6 · C1–C8 · F1–F8 · Portfolio"]
C2 --> V
C3 --> V
@@ -149,10 +169,14 @@ flowchart TB
V --> B["逆向隐含预期\n市值÷倍数 · 隐含利润 · 期权剩余价值"]
F --> G["预期差对账\n模型 vs 股价 · 可实现性 · 防重复计价"]
B --> G
- G --> K["动态投委会\n从15个框架选择最相关的6位"]
- K --> S["Committee Synthesis\n共识 · 分歧 · 风险否决 · 条件化动作"]
+ G --> K["框架分析\n从15个 lens 选择最相关的6位"]
+ K --> X["命题编译器\n提取 · 引证 · 校验 · 缩窄范围"]
+ X --> PC["可发布命题\nstrongly_supported · supported"]
+ X --> A["仅审计命题\nunsupported · speculative · unresolved conflict"]
+ PC --> S["Committee Synthesis\n共识 · 分歧 · 风险否决 · 条件化动作"]
S --> R["给投资者看的报告\n已计价什么 · 差异认知 · 触发器 · 跟踪清单"]
S --> W["可恢复 Research Workspace\n保存阶段结果 · 下次对比变化"]
+ A --> W
```
对普通投资者来说,可以把它理解成四步:
@@ -160,7 +184,7 @@ flowchart TB
1. 你用自然语言说清楚“研究谁”和“最想解决什么问题”。
2. 系统按场景寻找数据,并拒绝使用研究日期之后才披露的信息。
3. 从 15 个投资框架中挑选最匹配问题的 6 位组成投委会;不是每次都让同一批人发表模板化观点。
-4. 公司报告先对账正向模型和市值隐含利润,再给核心分歧、反证条件和后续观察指标。
+4. 公司和基金深度研究只有受支持命题进入委员会综合;未发布命题与覆盖缺口留在机器可读审计产物中。
核心边界是:**问题决定研究路径,代码负责取得和校验证据,投资框架只能解释已经存在的数据。** M1–M6 服务市场与组合;C1–C8 回答公司本身;F1–F8 回答基金合同、指数暴露、估值、跟踪与交易实现。
@@ -174,6 +198,39 @@ flowchart LR
V2 -- 不可以 --> G["保留为研究边界\n不补零、不猜测"]
```
+### 深度研究的可支持命题发布机制
+
+v4.16 的发布层有明确作用范围:仅对公司和基金 `--market research` 默认启用。市场日报、`stock-review`、`earnings`、`price-move`、快照与持仓报告保持既有契约;需要说明缺失模块的流程仍会在正文自然说明。
+
+```mermaid
+flowchart TB
+ E["冻结证据\n来源 · 日期 · 期间 · 口径"] --> X["提取结构化命题\n陈述 · evidence_ids · 条件 · 失效条件"]
+ X --> V{"离散支持规则"}
+ V -->|strongly_supported / supported| P["可发布命题"]
+ V -->|unsupported / speculative / conflicted_unresolved| U["仅进入审计层"]
+ P --> M{"证据缺口影响"}
+ M -->|no_material_effect| R["按原范围发布"]
+ M -->|narrows_scope| N["发布缩窄后的可靠命题"]
+ M -->|blocks_action| B["发布研究结论;阻断估值或执行动作"]
+ M -->|blocks_claim| U
+ R --> C["投委会 + 既有八段式报告"]
+ N --> C
+ B --> C
+ U --> A["claim_ledger.json\nunpublished_claims.json"]
+ C --> O["投资者 Markdown 正文"]
+```
+
+系统不使用连续置信分。每个命题只能处于五个状态之一:`strongly_supported`、`supported`、`unsupported`、`speculative`、`conflicted_unresolved`。普通证据缺口只过滤或缩窄相关命题,不得被转化为看空、中性、保守、观望或等待信号。
+
+| 完整性情形 | 发布结果 |
+|---|---|
+| 当前价格缺失 | 阻断当前估值与价格相关动作;保留已有经营研究 |
+| 总市值缺失 | 阻断反向估值;保留已有基本面研究 |
+| 流动性缺失 | 阻断交易执行计划;保留已有研究结论 |
+| 身份、报告口径、一手来源冲突、前视偏差无法解决,或没有相关可发布命题 | 阻断整份深度研究报告 |
+
+公开报告继续沿用公司和基金各自的八段式骨架,且永远不会自动推导仓位或输出无条件买卖指令。
+
## Agent 安装
### 不会编程:把这段话发给你的 Agent
@@ -196,7 +253,7 @@ Agent 完成后,你不需要记命令。直接说“深度分析 600519”“
```bash
git clone https://github.com/AdvancingTitans/stock-analysis.git
cd stock-analysis
-uv tool install stock-analysis
+uv tool install --force .
python3 scripts/sync_agent_entrypoints.py --check
scripts/install-agent-entrypoints.sh codex
scripts/install-agent-entrypoints.sh claude
@@ -260,7 +317,7 @@ scripts/install-agent-entrypoints.sh claude
## 报告示例
-建议先看两份最新报告,它们最能体现当前版本与普通 AI 研报的区别:
+以下代表性报告展示了确定性证据与投委会流程;v4.16 的命题发布契约以上方说明和 Workspace 审计产物为准。
| 场景 | 报告里真正解决的问题 | 示例 |
|---|---|---|
@@ -290,6 +347,7 @@ scripts/install-agent-entrypoints.sh claude
| A 股 / 港股 / 美股 / 日股 / 韩股 / 基金 / 持仓 | 一套入口覆盖市场复盘、单股、基金、财报、异动、筛选、持仓与投资论文。 |
| 多源校验与时间边界 | 优先使用稳定公开源;来源失败时自动降级;历史研究不会偷看未来披露。 |
| 可恢复研究工作区 | 分阶段保存研究计划、底稿、专家意见、委员会结论和最终报告,支持下次复查变化。 |
+| 可支持命题发布 | 公司/基金深度研究只发布带引证与条件的命题;被拒命题和缺口保留在四个固定 JSON 审计产物中。 |
| 人机两用结果 | Markdown 给投资者阅读,JSON Evidence Pack 给 Agent 校验、自动化和复用。 |
## 快速开始
@@ -381,6 +439,21 @@ stock-analysis --market diagnose
A/HK/US/JP/KR 日线现在统一输出本币 20 日平均成交额和 60 日波动率;完整持仓且样本对齐时,组合证据会生成两两相关性以及“本地价格收益/汇率收益/人民币收益”的逐日归因。实时组合估值不再用硬编码汇率兜底。历史订单簿、券商个性化佣金、基金真实净申赎仍保持显式缺口。
+### Fund Evidence Pack(F1–F8)
+
+基金研究同时核对场内产品与它实际持有的底层资产。模块名称在 Workspace 与命题台账中保持稳定。
+
+| 模块 | 用投资者语言回答的问题 | 证据重点 |
+|---|---|---|
+| F1 产品定位与指数契约 | 基金必须跟踪什么,遵守哪些规则? | 基金画像、标的指数、复制方式、样本与调仓规则 |
+| F2 成分暴露与集中度 | 组合实际持有什么? | 完整指数样本或已披露持仓、权重、前五/前十大集中度 |
+| F3 业绩与趋势 | 产品过去实现了什么? | 带明确期间的场内价格与基金画像收益 |
+| F4 跟踪、折溢价与交易实现 | 场内价格能否有效跟随净值和指数? | 折溢价、逐日对齐跟踪指标、交易实现证据 |
+| F5 底层估值 | 指数或已覆盖持仓对应什么估值? | 优先官方指数估值;持仓代理值必须标记为 conditional |
+| F6 风险预算与回撤代理 | 暴露有多大波动和集中风险? | 回撤、波动、beta、历史样本量与集中度代理 |
+| F7 治理、规模与运营 | 产品能否按预期持有和交易? | 费率、规模、基金经理、申赎状态、流动性与成本情景 |
+| F8 催化剂与跟踪条件 | 哪个可观察事件会改变判断? | 调仓、披露刷新、指标、日期、成立条件与失效条件 |
+
### 正向搭模型 + 逆向验市值
需要产品线、SOTP 或期权估值时,传入一份明确的 JSON 假设:
@@ -398,7 +471,23 @@ stock-analysis --market research --symbol 600519 \
`stock-analysis --market research --symbol <代码>` 会在 `~/.stock_analysis/research///` 下实体化机构化研究流程(可用 `STOCK_ANALYSIS_RESEARCH_DIR` 或 `--workspace-dir` 覆盖)。股票研究冻结 C1–C8 Company Evidence;基金研究使用 `--asset-type fund`(或由 auto 识别常见场内基金前缀),冻结独立的 F1–F8 Fund Evidence,覆盖产品契约、持仓集中度、业绩、跟踪与折溢价、底层估值缺口、风险、治理和跟踪条件。所有 lens 与 committee 必须消费同一内容寻址快照。同一日期重复运行会保护人工修改,并把新结果写到 `.generated` 文件。
-Company opinions 是确定性的框架复核,不是模拟专家发言:所有支持与反证都必须引用同一个冻结 `snapshot_id` 中的 `evidence_id`。committee 会拒绝混用不同快照,保留共识、分歧和 risk veto,只输出 `observe` 或 `manual_review`,不会自动推导仓位或交易动作。现有源补充了结构化财报/预告/快报、PE/PB/市值、融资现金流,以及治理/资本配置公告索引;聚合记录在回查公司或交易所原文前仍保持 secondary。
+```text
+~/.stock_analysis/research///
+├── 01-research-plan.md
+├── 02-frozen-company-evidence.json # 或 02-frozen-fund-evidence.json
+├── 03-evidence-summary.md
+├── 04-*-lens-opinions.json
+├── 05-committee-synthesis.json
+├── 06-decision-memo.md
+├── 07-institutional-report.md
+├── evidence_manifest.json
+├── claim_ledger.json
+├── coverage_report.json
+├── unpublished_claims.json
+└── workspace.json
+```
+
+Company opinions 是确定性的框架复核,不是模拟专家发言:所有支持与反证都必须引用同一个冻结 `snapshot_id` 中的 `evidence_id`。committee 会拒绝混用不同快照,并且只消费与研究问题相关的可发布命题。`publication_status` 为 `publish`、`block_action` 或 `block_report`;工作流 action 在报告完整性需要阻断前保持 `manual_review`,不会自动推导仓位或交易动作。现有源补充了结构化财报/预告/快报、PE/PB/市值、融资现金流,以及治理/资本配置公告索引;聚合记录在回查公司或交易所原文前仍保持 secondary。
每个财务事实都会记录期间、币种、会计范围、来源类型、来源和置信度,方便你回查数字来自哪里。[`config/metric_registry.json`](config/metric_registry.json) 规定指标如何校验、可被哪些框架使用;它不会输出综合“买入评分”。
@@ -466,10 +555,15 @@ If a module is missing, say which evidence was unavailable instead of guessing.
| 板块排行 | Eastmoney `clist` | Tonghuashun public pages → browser fallback |
| 港股行情 | Tencent/Sina | Eastmoney `stock/get` |
| 美股行情 | Sina/Tencent | Eastmoney `searchapi` → `stock/get` |
+| 日股/韩股行情与日线 | Yahoo chart;韩国由 Naver 交叉核对 | 代码或日历校验失败时保留显式缺失 |
+| 美股已申报财务 | SEC Company Facts,按 filing date 截止 | 通过范围与时点校验后的条件性全球三表 |
+| 公司一手证据 | 公司 IR → 交易所 → 监管机构 | 定向一手证据请求;无法解决的缺口继续保留 |
+| 中证指数成分与估值 | 中证指数官方文件 | 带条件覆盖标签的基金披露持仓 |
+| 组合估值汇率 | 实时公开 FX 路由 | 不使用硬编码实时汇率兜底 |
| 基金 | Eastmoney/Tiantian fund pages | Sina fund fallback |
| 深度 tick / order-book 数据 | Optional `mootdx` | Basic Tencent/Sina quotes |
-Yahoo 不是推荐默认路径的一部分,这是有意为之。
+Yahoo 不是 A/HK/US 行情的默认路径;仅用于文档明确列出的日/韩历史行情与全球财务条件路径,并执行代码、日期、口径和完整性校验。
## 投资者 Lens
@@ -481,9 +575,11 @@ Lens 会改变证据优先级和叙事结构,但不会绕过数据质量规则
### 内置 lens 与 committee 边界
-当前 CLI 版本为 `4.15.0`。
+当前 CLI 版本为 `4.16.0`。
+
+`research` 报告保留 4.5 系列的中文投委会分析密度,并把可恢复、可追溯的研究状态留在 Workspace 内部。个股沿用“执行摘要 → 行情与商业质量 → 财务增长 → 治理与资本配置 → 估值情景 → 投委会审议 → 风险催化 → 条件化动作”;基金沿用“执行摘要 → 产品与指数契约 → 持仓暴露 → 业绩风险 → 估值与交易实现 → 投委会审议 → 风险催化 → 条件化动作”。每位 lens 把结果隔离为 `publishable_claims` 和 `unpublished_questions`,committee 只消费前者。普通证据缺失只过滤或缩窄相关命题,不得被解释成看空、中性、保守、观望或等待信号。
-`research` 报告保留 4.5 系列的中文投委会分析密度,并把可恢复、可追溯的研究状态留在 Workspace 内部。个股沿用“执行摘要 → 行情与商业质量 → 财务增长 → 治理与资本配置 → 估值情景 → 投委会审议 → 风险催化 → 条件化动作”;基金沿用“执行摘要 → 产品与指数契约 → 持仓暴露 → 业绩风险 → 估值与交易实现 → 投委会审议 → 风险催化 → 条件化动作”。用户报告不显示 Coverage、Missing module、内部 action、快照 ID 或审计术语。
+每个发布命题必须引用冻结快照内的 `evidence_id`,并带有适用期、条件和失效条件。`unsupported`、`speculative`、`conflicted_unresolved` 命题不进入投资者正文,只保留在 `evidence_manifest.json`、`claim_ledger.json`、`coverage_report.json` 和 `unpublished_claims.json`。价格、总市值或流动性缺失只阻断估值或执行行动,不压低已有经营结论;身份、口径完整性、一手冲突、前视偏差或完全没有可发布命题时阻断整份报告。
LensEngine 是报告生成的核心编排器。`research` 默认使用 committee 模式,并根据用户问题从 15 个内置 lens 中选择最相关且互补的 6 位委员;用户显式指定专家时尊重用户选择。每位委员都会读取同一研究时点的全部结构化指标,再按自己的投资框架解释。自然语言调用也可表达为“用巴菲特模式分析贵州茅台”或“用 adversarial 模式让巴菲特和芒格辩论腾讯”;committee 失败时降级为 single,并在内部 metadata 保留原因。
@@ -521,7 +617,7 @@ Company 一手披露采用可扩展的“官方 PDF → 指定页文本 → JSON
提交到精选列表时,可以使用这句简介:
-> [stock-analysis](https://github.com/AdvancingTitans/stock-analysis) - Evidence-driven market recap CLI for AI agents and quant researchers, supporting A/HK/US/JP/KR stocks, funds, portfolios, auditable JSON Evidence Packs, data-quality scoring, investor lenses, and multi-source fallback routing.
+> [stock-analysis](https://github.com/AdvancingTitans/stock-analysis) - Evidence-first research CLI and Agent workflow for A/HK/US/JP/KR stocks, funds, and portfolios, with primary disclosures, forward/reverse valuation, recoverable Workspaces, supported-claim publication, and auditable JSON evidence contracts.
适合目标包括 `awesome-quant-ai`、`awesome-ai-in-finance`、`awesome-quant` 和 `awesome-systematic-trading`。
diff --git a/ROADMAP.md b/ROADMAP.md
index a6b6a4b..aea7a36 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,14 +1,14 @@
# Roadmap
-`stock-analysis` is already useful as a CLI. The next milestone is making it easier for strangers, AI agents, and contributors to trust it quickly.
+`stock-analysis` is available as a CLI, Python package, and generated Agent entrypoints. The next milestone is broadening primary-evidence coverage without weakening its deterministic research contracts.
## Near Term
-- Harden the install path: PyPI package, console script, `--help`, and first-run examples must stay in sync.
+- Keep the PyPI package, console script, `--help`, Agent entrypoints, and first-run examples in sync.
- Keep README screenshots current with real generated output.
- Add more compact example reports under `reports/`.
- Improve `diagnose` output so source failures are easy to attach to issues.
-- Add agent workflow examples for daily recap automation.
+- Add compact examples for auditing the Research Workspace claim ledger.
## Data Quality
@@ -20,22 +20,22 @@
## Market Coverage
- Strengthen A-share board, fund-flow, risk-calendar, and financial snapshot coverage.
-- Improve HK/US quote fallback and historical K-line consistency.
+- Continue hardening HK/US/JP/KR quote fallback and historical K-line consistency.
- Add more fund profile fields where public routes are stable.
- Document region-specific limitations before expanding new markets.
## Agent Workflows
-- Add a GitHub Actions daily recap example.
+- Keep the GitHub Actions daily recap example aligned with the current Evidence Pack contract.
- Add a cron example for local market notes.
-- Add a Codex/Hermes/Claude Code prompt recipe that verifies conclusions against evidence JSON.
+- Expand Codex/Hermes/Claude Code recipes that verify conclusions against evidence and claim JSON.
- Consider MCP only after the CLI and evidence contract are stable.
## Community
- Keep issue templates focused on data-source failures, feature requests, lens requests, and docs/examples.
- Submit small, well-targeted PRs to finance and AI-agent Awesome Lists.
-- Publish share images for GitHub social preview, X/LinkedIn, and Chinese technical communities.
+- Keep social previews, architecture GIFs, and bilingual demo videos aligned with each feature release.
## Out of Scope
diff --git a/agent-workflows/commands.json b/agent-workflows/commands.json
index dd8ff75..d937eca 100644
--- a/agent-workflows/commands.json
+++ b/agent-workflows/commands.json
@@ -10,5 +10,5 @@
{"id":"data-diagnose","summary":"检查当前数据源、浏览器接管边界和缺失字段。","cli":"stock-analysis --market diagnose","output":"把不可用数据源、fallback 与下一步补数路径明确告知用户。"},
{"id":"thesis-create","summary":"为一个标的创建本地、可审计的投资论文骨架。","cli":"stock-analysis --market thesis-create --symbol --emit-evidence","output":"只写入可验证事实;假设、反证与失效条件保持为空直到用户或一手资料补充。"},
{"id":"thesis-review","summary":"将当前 Company Evidence 与持久化论文快照比较。","cli":"stock-analysis --market thesis-review --symbol --emit-evidence","output":"区分事实变化、证据覆盖变化和仍需人工判断的论文变化。"},
- {"id":"research-workspace","summary":"为股票或基金生成可恢复、可审计的机构化研究工作区。","cli":"stock-analysis --market research --symbol [--asset-type company|fund]","output":"股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,并输出证据驱动的机构报告。"}
+ {"id":"research-workspace","summary":"为股票或基金生成可恢复、可审计的机构化研究工作区。","cli":"stock-analysis --market research --symbol [--asset-type company|fund]","output":"股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,最终报告只发布通过离散证据规则的 publishable_claims,普通缺口与未发布命题保留在四类审计 JSON。"}
]
diff --git a/assets/README.md b/assets/README.md
index 6a4924f..5c238ee 100644
--- a/assets/README.md
+++ b/assets/README.md
@@ -18,3 +18,35 @@ Screenshots and share cards used by the README and [report directory](../reports
- `social-preview.png`: GitHub repository social preview.
- `share-cn-1.png`: Chinese social-share card.
- `share-x-1.png`, `share-x-2.png`: short-form X/Twitter share cards.
+
+## Product Architecture and Demo
+
+- `investor-research-architecture-en.gif`: English investor-to-report architecture animation.
+- `investor-research-architecture-zh-CN.gif`: Simplified Chinese architecture animation.
+- `diagrams/investor-research-architecture-*.source.json`: semantic inputs for the English and Simplified Chinese architecture diagrams.
+- `diagrams/investor-research-architecture-*.svg`: validated semantic SVG sources used to render the GIFs.
+- `diagrams/investor-research-architecture-*.layout.json`: geometry and layout validation reports.
+- `diagrams/investor-research-architecture-*.motion.json`: GIF motion-contract validation reports.
+- `demo-video-preview-en.png`: English poster linking to the 72-second Remotion demo.
+- `demo-video-preview-zh-CN.png`: Simplified Chinese poster linking to the 72-second Remotion demo.
+
+The architecture animations use the `fireworks-tech-graph` `agent` template and
+the `agent-orchestration` motion contract. To reproduce one locale, replace the
+placeholders below with the installed skill path and the desired locale (`en` or
+`zh-CN`):
+
+```bash
+python3 /scripts/generate-from-template.py agent \
+ assets/diagrams/investor-research-architecture-.svg \
+ "$(jq -c . assets/diagrams/investor-research-architecture-.source.json)" \
+ --layout-report assets/diagrams/investor-research-architecture-.layout.json
+
+python3 /scripts/fireworks.py animate \
+ assets/diagrams/investor-research-architecture-.svg \
+ assets/investor-research-architecture-.gif \
+ --preset agent-orchestration --duration 5.75 --fps 20 --width 960 \
+ --report assets/diagrams/investor-research-architecture-.motion.json
+```
+
+The generator and animator require Python 3, Node.js 18+, Chrome/Chromium,
+FFmpeg/FFprobe, `jq`, and the runtime dependencies bundled with the skill.
diff --git a/assets/demo-video-preview-en.png b/assets/demo-video-preview-en.png
new file mode 100644
index 0000000..32c3fab
Binary files /dev/null and b/assets/demo-video-preview-en.png differ
diff --git a/assets/demo-video-preview-zh-CN.png b/assets/demo-video-preview-zh-CN.png
new file mode 100644
index 0000000..b0de759
Binary files /dev/null and b/assets/demo-video-preview-zh-CN.png differ
diff --git a/assets/diagrams/investor-research-architecture-en.layout.json b/assets/diagrams/investor-research-architecture-en.layout.json
new file mode 100644
index 0000000..4e6012d
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-en.layout.json
@@ -0,0 +1,351 @@
+{
+ "canvas": {
+ "height": 700.0,
+ "width": 960.0
+ },
+ "composition": {
+ "limits": {
+ "max_bends_per_edge": 2,
+ "max_bridged_crossings": 0,
+ "max_route_stretch": 1.35,
+ "max_total_bends": 8,
+ "min_container_gutter": 20.0,
+ "min_label_clearance": 4.0,
+ "min_node_gap": 40.0,
+ "min_segment_length": 16.0,
+ "profile": "showcase"
+ },
+ "metrics": {
+ "bridged_crossings": 0,
+ "max_route_stretch": 1.0,
+ "minimum_container_gutter": 20.0,
+ "minimum_node_gap": 110.0,
+ "shortest_segment": 20.4,
+ "total_bends": 4
+ },
+ "ok": true,
+ "profile": "showcase",
+ "score": 100,
+ "violations": []
+ },
+ "edges": [
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "brief",
+ "length": 170.0,
+ "route": [
+ [
+ 220.0,
+ 173.0
+ ],
+ [
+ 390.0,
+ 173.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "request",
+ "source_port": [
+ 220.0,
+ 173.0
+ ],
+ "target": "coordinator",
+ "target_port": [
+ 390.0,
+ 173.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 2,
+ "bridges": [],
+ "crossings": [],
+ "id": "a-delegate-research",
+ "length": 432.0,
+ "route": [
+ [
+ 472.0,
+ 200.0
+ ],
+ [
+ 472.0,
+ 306.0
+ ],
+ [
+ 170.0,
+ 306.0
+ ],
+ [
+ 170.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 472.0,
+ 200.0
+ ],
+ "target": "research",
+ "target_port": [
+ 170.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "b-delegate-coding",
+ "length": 130.0,
+ "route": [
+ [
+ 490.0,
+ 200.0
+ ],
+ [
+ 490.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 490.0,
+ 200.0
+ ],
+ "target": "coding",
+ "target_port": [
+ 490.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 2,
+ "bridges": [],
+ "crossings": [],
+ "id": "c-delegate-review",
+ "length": 412.0,
+ "route": [
+ [
+ 508.0,
+ 200.0
+ ],
+ [
+ 508.0,
+ 220.4
+ ],
+ [
+ 790.0,
+ 220.4
+ ],
+ [
+ 790.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 508.0,
+ 200.0
+ ],
+ "target": "review",
+ "target_port": [
+ 790.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "research-write",
+ "length": 120.0,
+ "route": [
+ [
+ 170.0,
+ 390.0
+ ],
+ [
+ 170.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "research",
+ "source_port": [
+ 170.0,
+ 390.0
+ ],
+ "target": "memory",
+ "target_port": [
+ 170.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "memory-merge",
+ "length": 130.0,
+ "route": [
+ [
+ 260.0,
+ 540.0
+ ],
+ [
+ 390.0,
+ 540.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "memory",
+ "source_port": [
+ 260.0,
+ 540.0
+ ],
+ "target": "synthesis",
+ "target_port": [
+ 390.0,
+ 540.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "coding-merge",
+ "length": 120.0,
+ "route": [
+ [
+ 490.0,
+ 390.0
+ ],
+ [
+ 490.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coding",
+ "source_port": [
+ 490.0,
+ 390.0
+ ],
+ "target": "synthesis",
+ "target_port": [
+ 490.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "review-output",
+ "length": 120.0,
+ "route": [
+ [
+ 790.0,
+ 390.0
+ ],
+ [
+ 790.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "review",
+ "source_port": [
+ 790.0,
+ 390.0
+ ],
+ "target": "response",
+ "target_port": [
+ 790.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "deliver",
+ "length": 110.0,
+ "route": [
+ [
+ 590.0,
+ 540.0
+ ],
+ [
+ 700.0,
+ 540.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "synthesis",
+ "source_port": [
+ 590.0,
+ 540.0
+ ],
+ "target": "response",
+ "target_port": [
+ 700.0,
+ 540.0
+ ],
+ "waypoints": []
+ }
+ ],
+ "input_schema": "v1",
+ "issues": [],
+ "mode": "agent",
+ "ok": true,
+ "placements": {
+ "legend": {
+ "actual": [
+ 48.0,
+ 625.0
+ ],
+ "bounds": [
+ 38.0,
+ 611.0,
+ 714.0,
+ 657.0
+ ],
+ "moved": false,
+ "requested": [
+ 48.0,
+ 625.0
+ ]
+ }
+ },
+ "schema_version": 1,
+ "semantics": {
+ "details": {},
+ "ok": true,
+ "profile": "generic",
+ "style": 5,
+ "visual_theme": "Glassmorphism"
+ },
+ "style": {
+ "id": 5,
+ "name": "Glassmorphism"
+ },
+ "summary": {
+ "bridged_crossings": 0,
+ "edges": 9,
+ "nodes": 8
+ },
+ "text_metrics": "heuristic-v1"
+}
diff --git a/assets/diagrams/investor-research-architecture-en.motion.json b/assets/diagrams/investor-research-architecture-en.motion.json
new file mode 100644
index 0000000..f820272
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-en.motion.json
@@ -0,0 +1,2397 @@
+{
+ "animation_contract": {
+ "connectors_visible_at_opening": false,
+ "draw_easing": "linear",
+ "draw_schedule": [
+ {
+ "frames": [
+ 1,
+ 6
+ ],
+ "order": 0,
+ "role": "ingress",
+ "stage": 1
+ },
+ {
+ "frames": [
+ 5,
+ 12
+ ],
+ "order": 0,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 8,
+ 15
+ ],
+ "order": 1,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 11,
+ 18
+ ],
+ "order": 2,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 17,
+ 24
+ ],
+ "order": 0,
+ "role": "evidence",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 20,
+ 27
+ ],
+ "order": 1,
+ "role": "artifact",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 25,
+ 30
+ ],
+ "order": 0,
+ "role": "context",
+ "stage": 4
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 0,
+ "role": "deliver",
+ "stage": 5
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 1,
+ "role": "approval",
+ "stage": 5
+ }
+ ],
+ "empty_opening_frame": 0,
+ "forbidden": [
+ "source-node-motion",
+ "source-text-motion",
+ "source-geometry-mutation",
+ "source-marker-mutation",
+ "camera-motion",
+ "animated-background"
+ ],
+ "maximum_concurrent_draws": 2,
+ "nodes_visible_every_frame": true,
+ "persistent_data_flow": {
+ "auxiliary": {
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo"
+ },
+ "body": {
+ "appended_below_labels_and_nodes": true,
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(maximum_live_width, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "direction_sentinels": [
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "ingress/0"
+ },
+ {
+ "directions": [
+ "down",
+ "left",
+ "down"
+ ],
+ "key": "delegate/0"
+ },
+ {
+ "directions": [
+ "down",
+ "right",
+ "down"
+ ],
+ "key": "delegate/2"
+ },
+ {
+ "directions": [
+ "down"
+ ],
+ "key": "evidence/0"
+ },
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "context/0"
+ }
+ ],
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1.0
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_keys": [
+ {
+ "order": 0,
+ "role": "ingress"
+ },
+ {
+ "order": 0,
+ "role": "delegate"
+ },
+ {
+ "order": 1,
+ "role": "delegate"
+ },
+ {
+ "order": 2,
+ "role": "delegate"
+ },
+ {
+ "order": 0,
+ "role": "evidence"
+ },
+ {
+ "order": 1,
+ "role": "artifact"
+ },
+ {
+ "order": 0,
+ "role": "context"
+ },
+ {
+ "order": 0,
+ "role": "deliver"
+ },
+ {
+ "order": 1,
+ "role": "approval"
+ }
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "tangent_aware_rotation": true,
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_100_percent": 6,
+ "travel_pixels_per_frame_at_50_percent": 3.0,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_label_opacity_states": 9,
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_markers": "original marker appears only after route arrival",
+ "settled_topology_dynamic": true,
+ "source_edges_hidden_by_transient_css": true,
+ "text_geometry_motion": 0,
+ "topology_draw_on": true
+ },
+ "artifact": {
+ "bytes": 376947,
+ "encoder": "ffmpeg/gif-palette",
+ "format": "gif",
+ "mime_type": "image/gif",
+ "path": "artifacts/investor-architecture-en.gif",
+ "probe": {
+ "codec_name": "gif",
+ "container": {
+ "duration": "5.750000",
+ "format_name": "gif"
+ },
+ "duration": "5.750000",
+ "height": 700,
+ "nb_frames": "115",
+ "nb_read_frames": "115",
+ "pix_fmt": "bgra",
+ "r_frame_rate": "20/1",
+ "width": 960
+ },
+ "sha256": "d3a6340ccbf3171c85ff76eb4ae93b6c6044f2f1110304012b84a2d5e5edb202",
+ "source_frame_count": 115
+ },
+ "dry_run": false,
+ "duration_seconds": 5.75,
+ "encoded_dimensions": {
+ "height": 700,
+ "width": 960
+ },
+ "fixture_sha256": "f4664045331c73179c312482b4d68d474513a059ede426891e097e615722b6a9",
+ "fps": 20,
+ "frame_count": 115,
+ "frame_uniqueness_contract": {
+ "adjacent_duplicates_allowed": false,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_frame": 110,
+ "long_timeline_repeat_scope": [
+ 38,
+ 109
+ ],
+ "minimum_unique_rasters_for_long_timeline": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "strict_all_unique_through_frame_count": 75
+ },
+ "height": 700,
+ "input_kind": "svg",
+ "loop": {
+ "adjacent_duplicate_count": 0,
+ "all_frames_unique": true,
+ "delta_quality": {
+ "adjacent_max": 0.0019009098039215685,
+ "adjacent_median": 0.0006683254901960784,
+ "adjacent_min": 6.547607843137255e-06,
+ "adjacent_p95": 0.0007820901960784314,
+ "changed_area_method": "accumulated adjacent luma delta greater than 2 percent",
+ "changed_area_ratio": 0.03977686274509804,
+ "metric": "normalized-luma-mean-absolute-difference",
+ "seam": 0.0001899627450980392,
+ "seam_within_adjacent_p95": true
+ },
+ "embedded_loop_count": 0,
+ "encoded_frames": {
+ "adjacent_duplicate_count": 0,
+ "adjacent_duplicate_pairs": [],
+ "algorithm": "framemd5",
+ "all_frames_unique": true,
+ "first_frame_hash": "58492aabb820fceaac5e017e2402cf68",
+ "frame_count": 115,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "intentional_reset_boundary_repeat_frame": 110,
+ "last_frame_hash": "4f4aa9c2ea64d36fdc49de5f33fe8aff",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_group_count": 0,
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "unique_frame_count": 115
+ },
+ "first_frame_sha256": "a0a89a56bb01b3ec952be1fbdc8b43c7fb628f088d248701a68c3c8535800ed0",
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "last_frame_sha256": "56749f7536f9a2da6e25f41b8003033257d20d6a5e69214d739621201819d84c",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "playback": "embedded-infinite",
+ "raw_frames": {
+ "adjacent_duplicate_count": 0,
+ "adjacent_duplicate_pairs": [],
+ "algorithm": "sha256",
+ "all_frames_unique": true,
+ "first_frame_hash": "a0a89a56bb01b3ec952be1fbdc8b43c7fb628f088d248701a68c3c8535800ed0",
+ "frame_count": 115,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "intentional_reset_boundary_repeat_frame": 110,
+ "last_frame_hash": "56749f7536f9a2da6e25f41b8003033257d20d6a5e69214d739621201819d84c",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_group_count": 0,
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "unique_frame_count": 115
+ },
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "sampling": "uniform-frame-centers",
+ "unique_frame_count": 115
+ },
+ "loop_playback": "embedded-infinite",
+ "mime_type": "image/gif",
+ "motion_class": "semantic",
+ "motion_grammar": {
+ "curves": {
+ "draw": "linear",
+ "persistent-data-flow": "linear",
+ "reset": "linear"
+ },
+ "draw_on": {
+ "connectors_visible_at_opening": false,
+ "draw_easing": "linear",
+ "draw_schedule": [
+ {
+ "frames": [
+ 1,
+ 6
+ ],
+ "order": 0,
+ "role": "ingress",
+ "stage": 1
+ },
+ {
+ "frames": [
+ 5,
+ 12
+ ],
+ "order": 0,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 8,
+ 15
+ ],
+ "order": 1,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 11,
+ 18
+ ],
+ "order": 2,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 17,
+ 24
+ ],
+ "order": 0,
+ "role": "evidence",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 20,
+ 27
+ ],
+ "order": 1,
+ "role": "artifact",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 25,
+ 30
+ ],
+ "order": 0,
+ "role": "context",
+ "stage": 4
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 0,
+ "role": "deliver",
+ "stage": 5
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 1,
+ "role": "approval",
+ "stage": 5
+ }
+ ],
+ "empty_opening_frame": 0,
+ "forbidden": [
+ "source-node-motion",
+ "source-text-motion",
+ "source-geometry-mutation",
+ "source-marker-mutation",
+ "camera-motion",
+ "animated-background"
+ ],
+ "maximum_concurrent_draws": 2,
+ "nodes_visible_every_frame": true,
+ "persistent_data_flow": {
+ "auxiliary": {
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo"
+ },
+ "body": {
+ "appended_below_labels_and_nodes": true,
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(maximum_live_width, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "direction_sentinels": [
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "ingress/0"
+ },
+ {
+ "directions": [
+ "down",
+ "left",
+ "down"
+ ],
+ "key": "delegate/0"
+ },
+ {
+ "directions": [
+ "down",
+ "right",
+ "down"
+ ],
+ "key": "delegate/2"
+ },
+ {
+ "directions": [
+ "down"
+ ],
+ "key": "evidence/0"
+ },
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "context/0"
+ }
+ ],
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1.0
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_keys": [
+ {
+ "order": 0,
+ "role": "ingress"
+ },
+ {
+ "order": 0,
+ "role": "delegate"
+ },
+ {
+ "order": 1,
+ "role": "delegate"
+ },
+ {
+ "order": 2,
+ "role": "delegate"
+ },
+ {
+ "order": 0,
+ "role": "evidence"
+ },
+ {
+ "order": 1,
+ "role": "artifact"
+ },
+ {
+ "order": 0,
+ "role": "context"
+ },
+ {
+ "order": 0,
+ "role": "deliver"
+ },
+ {
+ "order": 1,
+ "role": "approval"
+ }
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "tangent_aware_rotation": true,
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_100_percent": 6,
+ "travel_pixels_per_frame_at_50_percent": 3.0,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_label_opacity_states": 9,
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_markers": "original marker appears only after route arrival",
+ "settled_topology_dynamic": true,
+ "source_edges_hidden_by_transient_css": true,
+ "text_geometry_motion": 0,
+ "topology_draw_on": true
+ },
+ "duration_seconds": 5.75,
+ "frame_duration_ms": 50.0,
+ "phases": {
+ "draw": {
+ "frames": [
+ 1,
+ 36
+ ],
+ "seconds": [
+ 0.075,
+ 1.825
+ ]
+ },
+ "empty": {
+ "frames": [
+ 0,
+ 0
+ ],
+ "seconds": [
+ 0.025,
+ 0.025
+ ]
+ },
+ "full_opacity": {
+ "frames": [
+ 38,
+ 109
+ ],
+ "seconds": [
+ 1.925,
+ 5.475
+ ]
+ },
+ "reset": {
+ "frames": [
+ 110,
+ 114
+ ],
+ "seconds": [
+ 5.525,
+ 5.725
+ ]
+ },
+ "stream": {
+ "frames": [
+ 36,
+ 114
+ ],
+ "seconds": [
+ 1.825,
+ 5.725
+ ]
+ }
+ },
+ "sample_index_expression": "time * fps - 0.5",
+ "sampling": "uniform-frame-centers",
+ "version": "3.4"
+ },
+ "motion_grammar_version": "3.4",
+ "motion_scene": "agent-orchestration",
+ "ok": true,
+ "output": "artifacts/investor-architecture-en.gif",
+ "output_format": "gif",
+ "preset": "agent-orchestration",
+ "rendered_pixels": 77280000,
+ "renderer": {
+ "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "duration_seconds": 5.75,
+ "effects": 28,
+ "engine": "chromium-svg-draw-on-persistent-data-flow",
+ "fps": 20,
+ "frame_count": 115,
+ "height": 700,
+ "input_kind": "svg",
+ "loop_frame_policy": "integer-frame-index-centers-with-steady-state-plus-reset-boundary-repeat-scope",
+ "module": "puppeteer-core",
+ "module_version": "25.3.0",
+ "ok": true,
+ "paint_barrier": "fonts-ready-plus-two-animation-frames-before-capture",
+ "preset": "agent-orchestration",
+ "resolved_module": "/Users/yjw/.codex/skills/fireworks-tech-graph/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js",
+ "sandbox": "enabled",
+ "scene_report": {
+ "animated_background_count": 0,
+ "auxiliary_primitives": [
+ {
+ "animated_attributes": [
+ "opacity"
+ ],
+ "below_node": true,
+ "count": 1,
+ "geometry": {
+ "height": 62,
+ "width": 208,
+ "x": 386,
+ "y": 142
+ },
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo",
+ "source_geometry_mutated": false
+ }
+ ],
+ "camera_motion_count": 0,
+ "connectors_visible_at_opening": false,
+ "direction_sentinels": [
+ {
+ "actual": [
+ "right"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "right"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down",
+ "left",
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down",
+ "left",
+ "down"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down",
+ "right",
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down",
+ "right",
+ "down"
+ ],
+ "order": 2,
+ "passed": true,
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "right"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "right"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "context",
+ "schedule_key": "context/0",
+ "signature_advance_per_rendered_frame": 6
+ }
+ ],
+ "draw_clones": 9,
+ "draw_contract": {
+ "easing": "linear",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "source_geometry": "exact immutable edge clone"
+ },
+ "draw_schedule": [
+ {
+ "easing": "linear",
+ "edge_id": "brief",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 1,
+ 6
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "stage": 1
+ },
+ {
+ "easing": "linear",
+ "edge_id": "a-delegate-research",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 5,
+ 12
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "b-delegate-coding",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 8,
+ 15
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "c-delegate-review",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 2,
+ "rendered_frames": [
+ 11,
+ 18
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "research-write",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 17,
+ 24
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "stage": 3
+ },
+ {
+ "easing": "linear",
+ "edge_id": "coding-merge",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 20,
+ 27
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "stage": 3
+ },
+ {
+ "easing": "linear",
+ "edge_id": "memory-merge",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 25,
+ 30
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "stage": 4
+ },
+ {
+ "easing": "linear",
+ "edge_id": "deliver",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 29,
+ 36
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "stage": 5
+ },
+ {
+ "easing": "linear",
+ "edge_id": "review-output",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 29,
+ 36
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "stage": 5
+ }
+ ],
+ "dynamic_not_thicker_than_source": true,
+ "empty_opening_frame": 0,
+ "extra_scene_primitives": [
+ "glass-task-capsule",
+ "coordinator-halo"
+ ],
+ "grammar_version": "3.4",
+ "halo_count": 1,
+ "live_rail_width_ceiling": 2.2,
+ "live_rail_width_ceiling_passed": true,
+ "maximum_concurrent_draws": 2,
+ "maximum_live_rail_width": 2.2,
+ "node_motion": 0,
+ "node_motion_count": 0,
+ "nodes_visible_every_frame": true,
+ "packet_head_count": 0,
+ "persistent_packet_heads": [],
+ "persistent_stream_contract": {
+ "body": {
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dynamic_not_thicker_than_source": true,
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "source_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(2.2, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "phase_repeat_within_stream_interval": true,
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry_by_route": [
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "ingress/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/1"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/2"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "evidence/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "artifact/1"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "context/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "deliver/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "approval/1"
+ }
+ ],
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "signature_kind": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "stream_interval_frame_count": 79,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_50_percent": 3,
+ "travel_pixels_per_frame_at_960px": 6,
+ "travel_pixels_per_second_at_50_percent_20fps": 60,
+ "travel_pixels_per_second_at_960px_20fps": 120,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "persistent_streams": [
+ {
+ "color": "#60a5fa",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "brief",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 7,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 1,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "a-delegate-research",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 14,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "b-delegate-coding",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 17,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "c-delegate-review",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 20,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 2,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#34d399",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "research-write",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 21,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 3,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#34d399",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "coding-merge",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 24,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 3,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#60a5fa",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "memory-merge",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 28,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 4,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#f59e0b",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "deliver",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 35,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 5,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#f59e0b",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "review-output",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 38,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 5,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ }
+ ],
+ "preset": "agent-orchestration",
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "ripple_count": 0,
+ "route_label_clones": 9,
+ "route_label_opacity_states": 9,
+ "scene_identity": "Glassmorphism",
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_marker_clones": 9,
+ "settled_topology_dynamic": true,
+ "signature_kind": "glass-task-capsule",
+ "signature_travel_at_100_percent": 6,
+ "signature_travel_at_50_percent": 3,
+ "source_edges": 9,
+ "source_edges_hidden_by_transient_css": true,
+ "source_geometry_mutation_count": 0,
+ "source_marker_mutation_count": 0,
+ "source_route_labels": 9,
+ "source_text_mutation_count": 0,
+ "specialized_signature_count": 9,
+ "specialized_signatures": [
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 155
+ ],
+ "direction": "source-to-target",
+ "edge_id": "brief",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 22,
+ "initial_point": {
+ "x": 242,
+ "y": 173
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 170,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#60a5fa",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 417
+ ],
+ "direction": "source-to-target",
+ "edge_id": "a-delegate-research",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 29,
+ "initial_point": {
+ "x": 472,
+ "y": 229
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 432,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 115
+ ],
+ "direction": "source-to-target",
+ "edge_id": "b-delegate-coding",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 32,
+ "initial_point": {
+ "x": 490,
+ "y": 232
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 130,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 397
+ ],
+ "direction": "source-to-target",
+ "edge_id": "c-delegate-review",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 35,
+ "initial_point": {
+ "x": 522.5999755859375,
+ "y": 220.39999389648438
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 412,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "research-write",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 36,
+ "initial_point": {
+ "x": 170,
+ "y": 426
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#34d399",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "coding-merge",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 39,
+ "initial_point": {
+ "x": 490,
+ "y": 429
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#34d399",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 115
+ ],
+ "direction": "source-to-target",
+ "edge_id": "memory-merge",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 43,
+ "initial_point": {
+ "x": 303,
+ "y": 540
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 130,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#60a5fa",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 95
+ ],
+ "direction": "source-to-target",
+ "edge_id": "deliver",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 50,
+ "initial_point": {
+ "x": 640,
+ "y": 540
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 110,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#f59e0b",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "review-output",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 53,
+ "initial_point": {
+ "x": 790,
+ "y": 443
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#f59e0b",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ }
+ ],
+ "static_dom_guard": true,
+ "stream_count": 9,
+ "text_geometry_motion": 0,
+ "text_motion": 0,
+ "text_motion_count": 0,
+ "topology_draw_on": true,
+ "trace_span_reveals": [],
+ "waterfall_scanner": null
+ },
+ "width": 960
+ },
+ "report": "artifacts/investor-architecture-en.motion.json",
+ "required_encoder": "ffmpeg/gif",
+ "review_reference_source_sha256": "52bf52e8ac0b129fcfad8dcd06e93468b3b79e29e9e0f919be80cb58046c0991",
+ "review_status": "user-approved",
+ "runtime": {
+ "encoders": [
+ "gif"
+ ],
+ "ffmpeg": "/Users/yjw/Library/Application Support/ffmpeg-downloader/ffmpeg/ffmpeg",
+ "ffprobe": "/Users/yjw/Library/Application Support/ffmpeg-downloader/ffmpeg/ffprobe",
+ "format": {
+ "gif": true
+ },
+ "node": "/Users/yjw/.local/bin/node",
+ "ok": true,
+ "renderer": {
+ "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "deterministic_timeline": true,
+ "input": "semantic-svg",
+ "module": "puppeteer-core",
+ "module_version": "25.3.0",
+ "motion_model": "connector-draw-on-with-persistent-data-flow",
+ "ok": true,
+ "presets": [
+ "memory-weave",
+ "tool-grounding",
+ "service-blueprint",
+ "memory-lifecycle",
+ "agent-orchestration",
+ "governed-runtime",
+ "token-stream",
+ "golden-circuit",
+ "review-trace",
+ "cloud-flow",
+ "event-transit",
+ "ops-pulse"
+ ],
+ "resolved_module": "/Users/yjw/.codex/skills/fireworks-tech-graph/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js",
+ "sandbox_default": "enabled"
+ },
+ "worker": "/Users/yjw/.codex/skills/fireworks-tech-graph/scripts/svg2gif.js"
+ },
+ "scene_signature": {
+ "distinctive_primitives": [
+ "semantic-route-build",
+ "glass-handoff-rail",
+ "glass-task-capsule",
+ "coordinator-halo"
+ ],
+ "name": "glassmorphism-multi-agent-task-capsules"
+ },
+ "seamless_loop_contract": "75 frames or fewer: every raster is unique; more than 75 frames: non-adjacent repeats are allowed within the full-opacity interval, plus the sole intentional reset-boundary exception at reset_start with opacity 1.00, with at least 75 unique rasters; seam MAD must not exceed p95 adjacent-frame MAD",
+ "semantic_profile": "generic",
+ "semantics": {
+ "edges": 9,
+ "edges_without_flow": 0,
+ "motion_metadata": "explicit",
+ "nodes": 8,
+ "resolved_schedule": [
+ {
+ "edge_id": "brief",
+ "order": 0,
+ "role": "ingress",
+ "source_stroke": "#60a5fa",
+ "stage": 1
+ },
+ {
+ "edge_id": "a-delegate-research",
+ "order": 0,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "b-delegate-coding",
+ "order": 1,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "c-delegate-review",
+ "order": 2,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "research-write",
+ "order": 0,
+ "role": "evidence",
+ "source_stroke": "#34d399",
+ "stage": 3
+ },
+ {
+ "edge_id": "coding-merge",
+ "order": 1,
+ "role": "artifact",
+ "source_stroke": "#34d399",
+ "stage": 3
+ },
+ {
+ "edge_id": "memory-merge",
+ "order": 0,
+ "role": "context",
+ "source_stroke": "#60a5fa",
+ "stage": 4
+ },
+ {
+ "edge_id": "deliver",
+ "order": 0,
+ "role": "deliver",
+ "source_stroke": "#f59e0b",
+ "stage": 5
+ },
+ {
+ "edge_id": "review-output",
+ "order": 1,
+ "role": "approval",
+ "source_stroke": "#f59e0b",
+ "stage": 5
+ }
+ ],
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "staged_edges": 9
+ },
+ "size_guidance": {
+ "artifact_target_bytes": 500000,
+ "artifact_within_target": true,
+ "warnings": []
+ },
+ "source": "artifacts/investor-architecture-en.svg",
+ "source_checks": {
+ "composition": {
+ "details": [],
+ "ok": true
+ },
+ "geometry": {
+ "details": [],
+ "ok": true
+ },
+ "markers": {
+ "details": [],
+ "ok": true
+ },
+ "xml": {
+ "details": [],
+ "ok": true
+ }
+ },
+ "source_integrity": {
+ "sha256": "663af4c143e7b8e3d396966333f10cd8410fc3c1021baf289372f80ebecc2d56",
+ "sha256_after": "663af4c143e7b8e3d396966333f10cd8410fc3c1021baf289372f80ebecc2d56",
+ "sha256_before": "663af4c143e7b8e3d396966333f10cd8410fc3c1021baf289372f80ebecc2d56",
+ "unchanged": true
+ },
+ "source_sha256": "663af4c143e7b8e3d396966333f10cd8410fc3c1021baf289372f80ebecc2d56",
+ "style_contract": {
+ "approval_recorded_on": "2026-07-16",
+ "scope": "signature-speed-path-geometry-and-construction-schedule",
+ "source_policy": "semantic-schedule-and-geometry-not-exact-byte-hash",
+ "status": "user-approved"
+ },
+ "style_contract_status": "user-approved",
+ "style_id": 5,
+ "timing_revision": {
+ "approved_at": "2026-07-17",
+ "baseline": {
+ "duration_seconds": 3.75,
+ "frame_count": 75
+ },
+ "candidate": {
+ "added_full_opacity_frames": [
+ 70,
+ 109
+ ],
+ "duration_seconds": 5.75,
+ "frame_count": 115
+ },
+ "id": "+2s-settled-flow",
+ "only_pending_item": false,
+ "status": "user-approved"
+ },
+ "visual_theme": "Glassmorphism",
+ "width": 960
+}
diff --git a/assets/diagrams/investor-research-architecture-en.source.json b/assets/diagrams/investor-research-architecture-en.source.json
new file mode 100644
index 0000000..3cdf5b4
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-en.source.json
@@ -0,0 +1,51 @@
+{
+ "schema_version": 1,
+ "mode": "agent",
+ "template_type": "agent",
+ "style": 5,
+ "motion_scene": "agent-orchestration",
+ "quality_profile": "showcase",
+ "width": 960,
+ "height": 700,
+ "title": "From Evidence to Supported Conclusions",
+ "subtitle": "freeze the facts · validate each claim · publish supported conclusions · audit the rest",
+ "containers": [
+ { "id": "mission", "x": 40, "y": 110, "width": 880, "height": 110, "label": "Your Investment Question", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" },
+ { "id": "specialists", "x": 40, "y": 270, "width": 880, "height": 140, "label": "Claim Compiler", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" },
+ { "id": "delivery", "x": 40, "y": 450, "width": 880, "height": 140, "label": "OUTPUT", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" }
+ ],
+ "nodes": [
+ { "id": "request", "kind": "speech", "x": 60, "y": 146, "width": 160, "height": 54, "label": "Investment Question", "sublabel": "asset · date · concern", "fill": "rgba(255,255,255,0.12)", "stroke": "rgba(255,255,255,0.35)", "flat": true },
+ { "id": "coordinator", "kind": "double_rect", "x": 390, "y": 146, "width": 200, "height": 54, "type_label": "RESEARCH ROUTER", "label": "Route Evidence", "fill": "rgba(255,255,255,0.12)", "stroke": "#c084fc" },
+ { "id": "research", "kind": "rect", "x": 80, "y": 330, "width": 180, "height": 60, "type_label": "FREEZE", "label": "Freeze Facts", "fill": "rgba(255,255,255,0.12)", "stroke": "#60a5fa" },
+ { "id": "coding", "kind": "rect", "x": 390, "y": 330, "width": 200, "height": 60, "type_label": "MODEL", "label": "Model Price", "fill": "rgba(255,255,255,0.12)", "stroke": "#f59e0b" },
+ { "id": "review", "kind": "rect", "x": 700, "y": 330, "width": 180, "height": 60, "type_label": "CLAIMS", "label": "Classify Claims", "fill": "rgba(255,255,255,0.12)", "stroke": "#34d399" },
+ { "id": "memory", "kind": "cylinder", "x": 80, "y": 510, "width": 180, "height": 60, "label": "Evidence Log", "sublabel": "source · date · scope", "fill": "rgba(255,255,255,0.08)", "stroke": "#34d399" },
+ { "id": "synthesis", "kind": "double_rect", "x": 390, "y": 510, "width": 200, "height": 60, "type_label": "GATE", "label": "Publication Gate", "fill": "rgba(255,255,255,0.12)", "stroke": "#c084fc" },
+ { "id": "response", "kind": "speech", "x": 700, "y": 510, "width": 180, "height": 60, "label": "Report + Audit", "sublabel": "conditions · invalidators", "fill": "rgba(255,255,255,0.12)", "stroke": "#f59e0b", "flat": true }
+ ],
+ "arrows": [
+ { "id": "brief", "source": "request", "target": "coordinator", "source_port": "right", "target_port": "left", "flow": "read", "motion_role": "ingress", "motion_stage": 1, "motion_order": 0, "label": "define" },
+ { "id": "a-delegate-research", "source": "coordinator", "target": "research", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 0, "label": "facts" },
+ { "id": "b-delegate-coding", "source": "coordinator", "target": "coding", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 1, "label": "model" },
+ { "id": "c-delegate-review", "source": "coordinator", "target": "review", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 2, "label": "compile" },
+ { "id": "research-write", "source": "research", "target": "memory", "source_port": "bottom", "target_port": "top", "flow": "write", "motion_role": "evidence", "motion_stage": 3, "motion_order": 0, "label": "evidence" },
+ { "id": "memory-merge", "source": "memory", "target": "synthesis", "source_port": "right", "target_port": "left", "flow": "read", "motion_role": "context", "motion_stage": 4, "motion_order": 0, "label": "evidence ids" },
+ { "id": "coding-merge", "source": "coding", "target": "synthesis", "source_port": "bottom", "target_port": "top", "flow": "write", "motion_role": "artifact", "motion_stage": 3, "motion_order": 1, "label": "model claims" },
+ { "id": "review-output", "source": "review", "target": "response", "source_port": "bottom", "target_port": "top", "flow": "feedback", "motion_role": "approval", "motion_stage": 5, "motion_order": 1, "label": "audit only" },
+ { "id": "deliver", "source": "synthesis", "target": "response", "source_port": "right", "target_port": "left", "flow": "feedback", "motion_role": "deliver", "motion_stage": 5, "motion_order": 0, "label": "publish" }
+ ],
+ "legend_orientation": "horizontal",
+ "legend_x": 48,
+ "legend_y": 625,
+ "legend_locked": true,
+ "legend": [
+ { "flow": "read", "label": "question & facts" },
+ { "flow": "control", "label": "research paths" },
+ { "flow": "write", "label": "evidence & model" },
+ { "flow": "feedback", "label": "reviewed output" }
+ ],
+ "footer": "Ordinary gaps filter or narrow claims · unsupported claims stay audit-only · no automatic trades",
+ "footer_x": 48,
+ "footer_y": 680
+}
diff --git a/assets/diagrams/investor-research-architecture-en.svg b/assets/diagrams/investor-research-architecture-en.svg
new file mode 100644
index 0000000..376312c
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-en.svg
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ From Evidence to Supported Conclusions
+ freeze the facts · validate each claim · publish supported conclusions · audit the rest
+
+
+ YOUR INVESTMENT QUESTION
+
+
+
+
+ CLAIM COMPILER
+
+
+
+
+ OUTPUT
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Investment Question
+ asset · date · concern
+
+
+
+
+ RESEARCH ROUTER
+ Route Evidence
+
+
+
+ FREEZE
+ Freeze Facts
+
+
+
+ MODEL
+ Model Price
+
+
+
+ CLAIMS
+ Classify Claims
+
+
+
+
+
+
+
+ Evidence Log
+ source · date · scope
+
+
+
+
+ GATE
+ Publication Gate
+
+
+
+ Report + Audit
+ conditions · invalidators
+
+
+
+ define
+
+
+
+ facts
+
+
+
+ model
+
+
+
+ compile
+
+
+
+ evidence
+
+
+
+ evidence ids
+
+
+
+ model claims
+
+
+
+ audit only
+
+
+
+ publish
+
+
+
+
+ question & facts
+
+ research paths
+
+ evidence & model
+
+ reviewed output
+
+
+
\ No newline at end of file
diff --git a/assets/diagrams/investor-research-architecture-zh-CN.layout.json b/assets/diagrams/investor-research-architecture-zh-CN.layout.json
new file mode 100644
index 0000000..f67c628
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-zh-CN.layout.json
@@ -0,0 +1,351 @@
+{
+ "canvas": {
+ "height": 700.0,
+ "width": 960.0
+ },
+ "composition": {
+ "limits": {
+ "max_bends_per_edge": 2,
+ "max_bridged_crossings": 0,
+ "max_route_stretch": 1.35,
+ "max_total_bends": 8,
+ "min_container_gutter": 20.0,
+ "min_label_clearance": 4.0,
+ "min_node_gap": 40.0,
+ "min_segment_length": 16.0,
+ "profile": "showcase"
+ },
+ "metrics": {
+ "bridged_crossings": 0,
+ "max_route_stretch": 1.0,
+ "minimum_container_gutter": 20.0,
+ "minimum_node_gap": 110.0,
+ "shortest_segment": 20.4,
+ "total_bends": 4
+ },
+ "ok": true,
+ "profile": "showcase",
+ "score": 100,
+ "violations": []
+ },
+ "edges": [
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "brief",
+ "length": 170.0,
+ "route": [
+ [
+ 220.0,
+ 173.0
+ ],
+ [
+ 390.0,
+ 173.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "request",
+ "source_port": [
+ 220.0,
+ 173.0
+ ],
+ "target": "coordinator",
+ "target_port": [
+ 390.0,
+ 173.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 2,
+ "bridges": [],
+ "crossings": [],
+ "id": "a-delegate-research",
+ "length": 432.0,
+ "route": [
+ [
+ 472.0,
+ 200.0
+ ],
+ [
+ 472.0,
+ 306.0
+ ],
+ [
+ 170.0,
+ 306.0
+ ],
+ [
+ 170.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 472.0,
+ 200.0
+ ],
+ "target": "research",
+ "target_port": [
+ 170.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "b-delegate-coding",
+ "length": 130.0,
+ "route": [
+ [
+ 490.0,
+ 200.0
+ ],
+ [
+ 490.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 490.0,
+ 200.0
+ ],
+ "target": "coding",
+ "target_port": [
+ 490.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 2,
+ "bridges": [],
+ "crossings": [],
+ "id": "c-delegate-review",
+ "length": 412.0,
+ "route": [
+ [
+ 508.0,
+ 200.0
+ ],
+ [
+ 508.0,
+ 220.4
+ ],
+ [
+ 790.0,
+ 220.4
+ ],
+ [
+ 790.0,
+ 330.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coordinator",
+ "source_port": [
+ 508.0,
+ 200.0
+ ],
+ "target": "review",
+ "target_port": [
+ 790.0,
+ 330.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "research-write",
+ "length": 120.0,
+ "route": [
+ [
+ 170.0,
+ 390.0
+ ],
+ [
+ 170.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "research",
+ "source_port": [
+ 170.0,
+ 390.0
+ ],
+ "target": "memory",
+ "target_port": [
+ 170.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "memory-merge",
+ "length": 130.0,
+ "route": [
+ [
+ 260.0,
+ 540.0
+ ],
+ [
+ 390.0,
+ 540.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "memory",
+ "source_port": [
+ 260.0,
+ 540.0
+ ],
+ "target": "synthesis",
+ "target_port": [
+ 390.0,
+ 540.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "coding-merge",
+ "length": 120.0,
+ "route": [
+ [
+ 490.0,
+ 390.0
+ ],
+ [
+ 490.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "coding",
+ "source_port": [
+ 490.0,
+ 390.0
+ ],
+ "target": "synthesis",
+ "target_port": [
+ 490.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "review-output",
+ "length": 120.0,
+ "route": [
+ [
+ 790.0,
+ 390.0
+ ],
+ [
+ 790.0,
+ 510.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "review",
+ "source_port": [
+ 790.0,
+ 390.0
+ ],
+ "target": "response",
+ "target_port": [
+ 790.0,
+ 510.0
+ ],
+ "waypoints": []
+ },
+ {
+ "bends": 0,
+ "bridges": [],
+ "crossings": [],
+ "id": "deliver",
+ "length": 110.0,
+ "route": [
+ [
+ 590.0,
+ 540.0
+ ],
+ [
+ 700.0,
+ 540.0
+ ]
+ ],
+ "route_stretch": 1.0,
+ "source": "synthesis",
+ "source_port": [
+ 590.0,
+ 540.0
+ ],
+ "target": "response",
+ "target_port": [
+ 700.0,
+ 540.0
+ ],
+ "waypoints": []
+ }
+ ],
+ "input_schema": "v1",
+ "issues": [],
+ "mode": "agent",
+ "ok": true,
+ "placements": {
+ "legend": {
+ "actual": [
+ 48.0,
+ 625.0
+ ],
+ "bounds": [
+ 38.0,
+ 611.0,
+ 540.0,
+ 657.0
+ ],
+ "moved": false,
+ "requested": [
+ 48.0,
+ 625.0
+ ]
+ }
+ },
+ "schema_version": 1,
+ "semantics": {
+ "details": {},
+ "ok": true,
+ "profile": "generic",
+ "style": 5,
+ "visual_theme": "Glassmorphism"
+ },
+ "style": {
+ "id": 5,
+ "name": "Glassmorphism"
+ },
+ "summary": {
+ "bridged_crossings": 0,
+ "edges": 9,
+ "nodes": 8
+ },
+ "text_metrics": "heuristic-v1"
+}
diff --git a/assets/diagrams/investor-research-architecture-zh-CN.motion.json b/assets/diagrams/investor-research-architecture-zh-CN.motion.json
new file mode 100644
index 0000000..ec2886d
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-zh-CN.motion.json
@@ -0,0 +1,2397 @@
+{
+ "animation_contract": {
+ "connectors_visible_at_opening": false,
+ "draw_easing": "linear",
+ "draw_schedule": [
+ {
+ "frames": [
+ 1,
+ 6
+ ],
+ "order": 0,
+ "role": "ingress",
+ "stage": 1
+ },
+ {
+ "frames": [
+ 5,
+ 12
+ ],
+ "order": 0,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 8,
+ 15
+ ],
+ "order": 1,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 11,
+ 18
+ ],
+ "order": 2,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 17,
+ 24
+ ],
+ "order": 0,
+ "role": "evidence",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 20,
+ 27
+ ],
+ "order": 1,
+ "role": "artifact",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 25,
+ 30
+ ],
+ "order": 0,
+ "role": "context",
+ "stage": 4
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 0,
+ "role": "deliver",
+ "stage": 5
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 1,
+ "role": "approval",
+ "stage": 5
+ }
+ ],
+ "empty_opening_frame": 0,
+ "forbidden": [
+ "source-node-motion",
+ "source-text-motion",
+ "source-geometry-mutation",
+ "source-marker-mutation",
+ "camera-motion",
+ "animated-background"
+ ],
+ "maximum_concurrent_draws": 2,
+ "nodes_visible_every_frame": true,
+ "persistent_data_flow": {
+ "auxiliary": {
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo"
+ },
+ "body": {
+ "appended_below_labels_and_nodes": true,
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(maximum_live_width, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "direction_sentinels": [
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "ingress/0"
+ },
+ {
+ "directions": [
+ "down",
+ "left",
+ "down"
+ ],
+ "key": "delegate/0"
+ },
+ {
+ "directions": [
+ "down",
+ "right",
+ "down"
+ ],
+ "key": "delegate/2"
+ },
+ {
+ "directions": [
+ "down"
+ ],
+ "key": "evidence/0"
+ },
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "context/0"
+ }
+ ],
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1.0
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_keys": [
+ {
+ "order": 0,
+ "role": "ingress"
+ },
+ {
+ "order": 0,
+ "role": "delegate"
+ },
+ {
+ "order": 1,
+ "role": "delegate"
+ },
+ {
+ "order": 2,
+ "role": "delegate"
+ },
+ {
+ "order": 0,
+ "role": "evidence"
+ },
+ {
+ "order": 1,
+ "role": "artifact"
+ },
+ {
+ "order": 0,
+ "role": "context"
+ },
+ {
+ "order": 0,
+ "role": "deliver"
+ },
+ {
+ "order": 1,
+ "role": "approval"
+ }
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "tangent_aware_rotation": true,
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_100_percent": 6,
+ "travel_pixels_per_frame_at_50_percent": 3.0,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_label_opacity_states": 9,
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_markers": "original marker appears only after route arrival",
+ "settled_topology_dynamic": true,
+ "source_edges_hidden_by_transient_css": true,
+ "text_geometry_motion": 0,
+ "topology_draw_on": true
+ },
+ "artifact": {
+ "bytes": 379168,
+ "encoder": "ffmpeg/gif-palette",
+ "format": "gif",
+ "mime_type": "image/gif",
+ "path": "artifacts/investor-architecture-zh.gif",
+ "probe": {
+ "codec_name": "gif",
+ "container": {
+ "duration": "5.750000",
+ "format_name": "gif"
+ },
+ "duration": "5.750000",
+ "height": 700,
+ "nb_frames": "115",
+ "nb_read_frames": "115",
+ "pix_fmt": "bgra",
+ "r_frame_rate": "20/1",
+ "width": 960
+ },
+ "sha256": "78cc3718d04cd0d67b8502f6e8bfc95f5a88d34e874cbf4f30461efef5b12a69",
+ "source_frame_count": 115
+ },
+ "dry_run": false,
+ "duration_seconds": 5.75,
+ "encoded_dimensions": {
+ "height": 700,
+ "width": 960
+ },
+ "fixture_sha256": "f4664045331c73179c312482b4d68d474513a059ede426891e097e615722b6a9",
+ "fps": 20,
+ "frame_count": 115,
+ "frame_uniqueness_contract": {
+ "adjacent_duplicates_allowed": false,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_frame": 110,
+ "long_timeline_repeat_scope": [
+ 38,
+ 109
+ ],
+ "minimum_unique_rasters_for_long_timeline": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "strict_all_unique_through_frame_count": 75
+ },
+ "height": 700,
+ "input_kind": "svg",
+ "loop": {
+ "adjacent_duplicate_count": 0,
+ "all_frames_unique": true,
+ "delta_quality": {
+ "adjacent_max": 0.0020224901960784318,
+ "adjacent_median": 0.0006683254901960784,
+ "adjacent_min": 6.547607843137255e-06,
+ "adjacent_p95": 0.0007820901960784314,
+ "changed_area_method": "accumulated adjacent luma delta greater than 2 percent",
+ "changed_area_ratio": 0.03937333333333334,
+ "metric": "normalized-luma-mean-absolute-difference",
+ "seam": 0.00020533960784313725,
+ "seam_within_adjacent_p95": true
+ },
+ "embedded_loop_count": 0,
+ "encoded_frames": {
+ "adjacent_duplicate_count": 0,
+ "adjacent_duplicate_pairs": [],
+ "algorithm": "framemd5",
+ "all_frames_unique": true,
+ "first_frame_hash": "225b9987bf79dfcbc46f67b782eac819",
+ "frame_count": 115,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "intentional_reset_boundary_repeat_frame": 110,
+ "last_frame_hash": "9463ab6fe3e69ebf9cea27eea7af166f",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_group_count": 0,
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "unique_frame_count": 115
+ },
+ "first_frame_sha256": "01eb527499fbe22e71c09e6641d7cdf5d199da795bea22277dee9b796eb27611",
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "last_frame_sha256": "04f320803fdba26b99ecb53353267ce2dfa1b1660ba7f1fd3c90260d40f91c65",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "playback": "embedded-infinite",
+ "raw_frames": {
+ "adjacent_duplicate_count": 0,
+ "adjacent_duplicate_pairs": [],
+ "algorithm": "sha256",
+ "all_frames_unique": true,
+ "first_frame_hash": "01eb527499fbe22e71c09e6641d7cdf5d199da795bea22277dee9b796eb27611",
+ "frame_count": 115,
+ "intentional_reset_boundary_opacity": 1.0,
+ "intentional_reset_boundary_repeat_count": 0,
+ "intentional_reset_boundary_repeat_evidence": [],
+ "intentional_reset_boundary_repeat_frame": 110,
+ "last_frame_hash": "04f320803fdba26b99ecb53353267ce2dfa1b1660ba7f1fd3c90260d40f91c65",
+ "minimum_unique_frame_count": 75,
+ "opening_construction_and_reset_tail_globally_distinct": true,
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_group_count": 0,
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "unique_frame_count": 115
+ },
+ "repeat_scope": [
+ 38,
+ 109
+ ],
+ "repeat_scope_policy": "non-adjacent-repeats-inside-full-opacity-plus-reset-start-boundary",
+ "repeated_frame_index_evidence": [],
+ "repeats_confined_to_full_opacity": true,
+ "repeats_confined_to_permitted_scope": true,
+ "sampling": "uniform-frame-centers",
+ "unique_frame_count": 115
+ },
+ "loop_playback": "embedded-infinite",
+ "mime_type": "image/gif",
+ "motion_class": "semantic",
+ "motion_grammar": {
+ "curves": {
+ "draw": "linear",
+ "persistent-data-flow": "linear",
+ "reset": "linear"
+ },
+ "draw_on": {
+ "connectors_visible_at_opening": false,
+ "draw_easing": "linear",
+ "draw_schedule": [
+ {
+ "frames": [
+ 1,
+ 6
+ ],
+ "order": 0,
+ "role": "ingress",
+ "stage": 1
+ },
+ {
+ "frames": [
+ 5,
+ 12
+ ],
+ "order": 0,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 8,
+ 15
+ ],
+ "order": 1,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 11,
+ 18
+ ],
+ "order": 2,
+ "role": "delegate",
+ "stage": 2
+ },
+ {
+ "frames": [
+ 17,
+ 24
+ ],
+ "order": 0,
+ "role": "evidence",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 20,
+ 27
+ ],
+ "order": 1,
+ "role": "artifact",
+ "stage": 3
+ },
+ {
+ "frames": [
+ 25,
+ 30
+ ],
+ "order": 0,
+ "role": "context",
+ "stage": 4
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 0,
+ "role": "deliver",
+ "stage": 5
+ },
+ {
+ "frames": [
+ 29,
+ 36
+ ],
+ "order": 1,
+ "role": "approval",
+ "stage": 5
+ }
+ ],
+ "empty_opening_frame": 0,
+ "forbidden": [
+ "source-node-motion",
+ "source-text-motion",
+ "source-geometry-mutation",
+ "source-marker-mutation",
+ "camera-motion",
+ "animated-background"
+ ],
+ "maximum_concurrent_draws": 2,
+ "nodes_visible_every_frame": true,
+ "persistent_data_flow": {
+ "auxiliary": {
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo"
+ },
+ "body": {
+ "appended_below_labels_and_nodes": true,
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(maximum_live_width, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "direction_sentinels": [
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "ingress/0"
+ },
+ {
+ "directions": [
+ "down",
+ "left",
+ "down"
+ ],
+ "key": "delegate/0"
+ },
+ {
+ "directions": [
+ "down",
+ "right",
+ "down"
+ ],
+ "key": "delegate/2"
+ },
+ {
+ "directions": [
+ "down"
+ ],
+ "key": "evidence/0"
+ },
+ {
+ "directions": [
+ "right"
+ ],
+ "key": "context/0"
+ }
+ ],
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1.0
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_keys": [
+ {
+ "order": 0,
+ "role": "ingress"
+ },
+ {
+ "order": 0,
+ "role": "delegate"
+ },
+ {
+ "order": 1,
+ "role": "delegate"
+ },
+ {
+ "order": 2,
+ "role": "delegate"
+ },
+ {
+ "order": 0,
+ "role": "evidence"
+ },
+ {
+ "order": 1,
+ "role": "artifact"
+ },
+ {
+ "order": 0,
+ "role": "context"
+ },
+ {
+ "order": 0,
+ "role": "deliver"
+ },
+ {
+ "order": 1,
+ "role": "approval"
+ }
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "tangent_aware_rotation": true,
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_100_percent": 6,
+ "travel_pixels_per_frame_at_50_percent": 3.0,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1.0,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "route_label_opacity_states": 9,
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_markers": "original marker appears only after route arrival",
+ "settled_topology_dynamic": true,
+ "source_edges_hidden_by_transient_css": true,
+ "text_geometry_motion": 0,
+ "topology_draw_on": true
+ },
+ "duration_seconds": 5.75,
+ "frame_duration_ms": 50.0,
+ "phases": {
+ "draw": {
+ "frames": [
+ 1,
+ 36
+ ],
+ "seconds": [
+ 0.075,
+ 1.825
+ ]
+ },
+ "empty": {
+ "frames": [
+ 0,
+ 0
+ ],
+ "seconds": [
+ 0.025,
+ 0.025
+ ]
+ },
+ "full_opacity": {
+ "frames": [
+ 38,
+ 109
+ ],
+ "seconds": [
+ 1.925,
+ 5.475
+ ]
+ },
+ "reset": {
+ "frames": [
+ 110,
+ 114
+ ],
+ "seconds": [
+ 5.525,
+ 5.725
+ ]
+ },
+ "stream": {
+ "frames": [
+ 36,
+ 114
+ ],
+ "seconds": [
+ 1.825,
+ 5.725
+ ]
+ }
+ },
+ "sample_index_expression": "time * fps - 0.5",
+ "sampling": "uniform-frame-centers",
+ "version": "3.4"
+ },
+ "motion_grammar_version": "3.4",
+ "motion_scene": "agent-orchestration",
+ "ok": true,
+ "output": "artifacts/investor-architecture-zh.gif",
+ "output_format": "gif",
+ "preset": "agent-orchestration",
+ "rendered_pixels": 77280000,
+ "renderer": {
+ "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "duration_seconds": 5.75,
+ "effects": 28,
+ "engine": "chromium-svg-draw-on-persistent-data-flow",
+ "fps": 20,
+ "frame_count": 115,
+ "height": 700,
+ "input_kind": "svg",
+ "loop_frame_policy": "integer-frame-index-centers-with-steady-state-plus-reset-boundary-repeat-scope",
+ "module": "puppeteer-core",
+ "module_version": "25.3.0",
+ "ok": true,
+ "paint_barrier": "fonts-ready-plus-two-animation-frames-before-capture",
+ "preset": "agent-orchestration",
+ "resolved_module": "/Users/yjw/.codex/skills/fireworks-tech-graph/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js",
+ "sandbox": "enabled",
+ "scene_report": {
+ "animated_background_count": 0,
+ "auxiliary_primitives": [
+ {
+ "animated_attributes": [
+ "opacity"
+ ],
+ "below_node": true,
+ "count": 1,
+ "geometry": {
+ "height": 62,
+ "width": 208,
+ "x": 386,
+ "y": 142
+ },
+ "movement": "opacity-only",
+ "node_id": "coordinator",
+ "opacity_range": [
+ 0.12,
+ 0.32
+ ],
+ "period_frames": 16,
+ "primitive": "coordinator-halo",
+ "source_geometry_mutated": false
+ }
+ ],
+ "camera_motion_count": 0,
+ "connectors_visible_at_opening": false,
+ "direction_sentinels": [
+ {
+ "actual": [
+ "right"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "right"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down",
+ "left",
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down",
+ "left",
+ "down"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down",
+ "right",
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down",
+ "right",
+ "down"
+ ],
+ "order": 2,
+ "passed": true,
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "down"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "down"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "signature_advance_per_rendered_frame": 6
+ },
+ {
+ "actual": [
+ "right"
+ ],
+ "dash_offset_per_rendered_frame": -6,
+ "expected": [
+ "right"
+ ],
+ "order": 0,
+ "passed": true,
+ "role": "context",
+ "schedule_key": "context/0",
+ "signature_advance_per_rendered_frame": 6
+ }
+ ],
+ "draw_clones": 9,
+ "draw_contract": {
+ "easing": "linear",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "source_geometry": "exact immutable edge clone"
+ },
+ "draw_schedule": [
+ {
+ "easing": "linear",
+ "edge_id": "brief",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 1,
+ 6
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "stage": 1
+ },
+ {
+ "easing": "linear",
+ "edge_id": "a-delegate-research",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 5,
+ 12
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "b-delegate-coding",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 8,
+ 15
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "c-delegate-review",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 2,
+ "rendered_frames": [
+ 11,
+ 18
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "stage": 2
+ },
+ {
+ "easing": "linear",
+ "edge_id": "research-write",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 17,
+ 24
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "stage": 3
+ },
+ {
+ "easing": "linear",
+ "edge_id": "coding-merge",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 20,
+ 27
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "stage": 3
+ },
+ {
+ "easing": "linear",
+ "edge_id": "memory-merge",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 25,
+ 30
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "stage": 4
+ },
+ {
+ "easing": "linear",
+ "edge_id": "deliver",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 0,
+ "rendered_frames": [
+ 29,
+ 36
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "stage": 5
+ },
+ {
+ "easing": "linear",
+ "edge_id": "review-output",
+ "marker_after_arrival": true,
+ "marker_during_draw": false,
+ "order": 1,
+ "rendered_frames": [
+ 29,
+ 36
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "stage": 5
+ }
+ ],
+ "dynamic_not_thicker_than_source": true,
+ "empty_opening_frame": 0,
+ "extra_scene_primitives": [
+ "glass-task-capsule",
+ "coordinator-halo"
+ ],
+ "grammar_version": "3.4",
+ "halo_count": 1,
+ "live_rail_width_ceiling": 2.2,
+ "live_rail_width_ceiling_passed": true,
+ "maximum_concurrent_draws": 2,
+ "maximum_live_rail_width": 2.2,
+ "node_motion": 0,
+ "node_motion_count": 0,
+ "nodes_visible_every_frame": true,
+ "packet_head_count": 0,
+ "persistent_packet_heads": [],
+ "persistent_stream_contract": {
+ "body": {
+ "color": "inherit-source-stroke",
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dynamic_not_thicker_than_source": true,
+ "filter_free": true,
+ "linecap": "round",
+ "linejoin": "round",
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "opacity": 0.88,
+ "primitive": "glass-handoff-rail",
+ "resolved_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "source_widths_in_schedule_order": [
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2,
+ 2.2
+ ],
+ "stroke_width": "min(2.2, source_stroke)"
+ },
+ "dash_offset_per_rendered_frame": -6,
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "expected_initial_phases": [
+ 7,
+ 14,
+ 17,
+ 20,
+ 21,
+ 24,
+ 28,
+ 35,
+ 38
+ ],
+ "fade_in_factors": [
+ 0.3,
+ 0.65,
+ 1
+ ],
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "minimum_review_scale": "50%",
+ "period_step_coprime": true,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "phase_repeat_within_stream_interval": true,
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "reset_behavior": "live rails, signatures, and scene auxiliaries keep advancing while the shared reset opacity fades to 0.03",
+ "reset_opacity_samples": [
+ 1,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "signature": {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "direction": "source-to-target",
+ "endpoint_clearance": 8,
+ "geometry_by_route": [
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "ingress/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/1"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "delegate/2"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "evidence/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "artifact/1"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "context/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "deliver/0"
+ },
+ {
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "primitive": "glass-task-capsule",
+ "schedule_key": "approval/1"
+ }
+ ],
+ "path_advance_per_rendered_frame": 6,
+ "primitive": "glass-task-capsule",
+ "signature_kind": "glass-task-capsule",
+ "tangent_aware_rotation": true,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ "signature_advance_per_rendered_frame": 6,
+ "signature_count": 9,
+ "signature_primitive": "glass-task-capsule",
+ "stream_count": 9,
+ "stream_interval_frame_count": 79,
+ "travel_easing": "linear",
+ "travel_pixels_per_frame_at_50_percent": 3,
+ "travel_pixels_per_frame_at_960px": 6,
+ "travel_pixels_per_second_at_50_percent_20fps": 60,
+ "travel_pixels_per_second_at_960px_20fps": 120,
+ "travel_user_units_per_rendered_frame": 6
+ },
+ "persistent_streams": [
+ {
+ "color": "#60a5fa",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "brief",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 7,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 1,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "a-delegate-research",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 14,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "b-delegate-coding",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 17,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#c084fc",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "c-delegate-review",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 20,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 2,
+ "motion_stage": 2,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#34d399",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "research-write",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 21,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 3,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#34d399",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "coding-merge",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 24,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 3,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#60a5fa",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "memory-merge",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 28,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 4,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#f59e0b",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "deliver",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 35,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 0,
+ "motion_stage": 5,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ },
+ {
+ "color": "#f59e0b",
+ "dash_offset_per_rendered_frame": -6,
+ "dash_pattern": [
+ 13,
+ 30
+ ],
+ "dash_period": 43,
+ "direction": "source-to-target",
+ "dynamic_not_thicker_than_source": true,
+ "edge_id": "review-output",
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_free": true,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "initial_phase": 38,
+ "marker_free": true,
+ "maximum_live_width": 2.2,
+ "motion_order": 1,
+ "motion_stage": 5,
+ "opacity": 0.88,
+ "phase_policy": "(motionStage * 7 + motionOrder * 3) mod 43",
+ "primitive": "glass-handoff-rail",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "source_stroke_width": 2.2,
+ "stroke_width": 2.2,
+ "travel_easing": "linear"
+ }
+ ],
+ "preset": "agent-orchestration",
+ "primitive": "connector-draw-on-with-persistent-data-flow",
+ "reset_opacity_samples": [
+ 1,
+ 0.7575,
+ 0.515,
+ 0.2725,
+ 0.03
+ ],
+ "reset_range": [
+ 110,
+ 114
+ ],
+ "ripple_count": 0,
+ "route_label_clones": 9,
+ "route_label_opacity_states": 9,
+ "scene_identity": "Glassmorphism",
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "settled_marker_clones": 9,
+ "settled_topology_dynamic": true,
+ "signature_kind": "glass-task-capsule",
+ "signature_travel_at_100_percent": 6,
+ "signature_travel_at_50_percent": 3,
+ "source_edges": 9,
+ "source_edges_hidden_by_transient_css": true,
+ "source_geometry_mutation_count": 0,
+ "source_marker_mutation_count": 0,
+ "source_route_labels": 9,
+ "source_text_mutation_count": 0,
+ "specialized_signature_count": 9,
+ "specialized_signatures": [
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 155
+ ],
+ "direction": "source-to-target",
+ "edge_id": "brief",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 22,
+ "initial_point": {
+ "x": 242,
+ "y": 173
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 170,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "ingress",
+ "schedule_key": "ingress/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#60a5fa",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 417
+ ],
+ "direction": "source-to-target",
+ "edge_id": "a-delegate-research",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 29,
+ "initial_point": {
+ "x": 472,
+ "y": 229
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 432,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 115
+ ],
+ "direction": "source-to-target",
+ "edge_id": "b-delegate-coding",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 32,
+ "initial_point": {
+ "x": 490,
+ "y": 232
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 130,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 397
+ ],
+ "direction": "source-to-target",
+ "edge_id": "c-delegate-review",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 35,
+ "initial_point": {
+ "x": 522.5999755859375,
+ "y": 220.39999389648438
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 412,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "delegate",
+ "schedule_key": "delegate/2",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#c084fc",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "research-write",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 36,
+ "initial_point": {
+ "x": 170,
+ "y": 426
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "evidence",
+ "schedule_key": "evidence/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#34d399",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "coding-merge",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 39,
+ "initial_point": {
+ "x": 490,
+ "y": 429
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "artifact",
+ "schedule_key": "artifact/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#34d399",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 115
+ ],
+ "direction": "source-to-target",
+ "edge_id": "memory-merge",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 43,
+ "initial_point": {
+ "x": 303,
+ "y": 540
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 130,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "context",
+ "schedule_key": "context/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#60a5fa",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 95
+ ],
+ "direction": "source-to-target",
+ "edge_id": "deliver",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 50,
+ "initial_point": {
+ "x": 640,
+ "y": 540
+ },
+ "initial_tangent_rotation": 0,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 110,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "deliver",
+ "schedule_key": "deliver/0",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#f59e0b",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ },
+ {
+ "animated_attributes": [
+ "transform",
+ "opacity"
+ ],
+ "appended_below_labels_and_nodes": true,
+ "center_travel_range": [
+ 15,
+ 105
+ ],
+ "direction": "source-to-target",
+ "edge_id": "review-output",
+ "endpoint_clearance": 8,
+ "fade_in_frames": [
+ 36,
+ 38
+ ],
+ "filter_boundary_valid": true,
+ "filtered_element_count": 0,
+ "full_opacity_frames": [
+ 38,
+ 109
+ ],
+ "geometry": {
+ "height": 9,
+ "highlight_stroke_width": 1,
+ "rx": 3,
+ "shape": "rounded-translucent-plate",
+ "width": 14,
+ "work_item_dot_count": 2,
+ "work_item_dot_radius": 2
+ },
+ "geometry_forward_extent": 7,
+ "geometry_rear_extent": 7,
+ "initial_path_distance": 53,
+ "initial_point": {
+ "x": 790,
+ "y": 443
+ },
+ "initial_tangent_rotation": 90,
+ "marker_free": true,
+ "path_advance_per_rendered_frame": 6,
+ "path_length": 120,
+ "primitive": "glass-task-capsule",
+ "rendered_frames": [
+ 36,
+ 114
+ ],
+ "role": "approval",
+ "schedule_key": "approval/1",
+ "signature_family": "glass-task-capsule",
+ "source_color": "#f59e0b",
+ "travel_pixels_per_rendered_frame_at_50_percent": 3,
+ "wrap": "target-clearance-to-source-clearance"
+ }
+ ],
+ "static_dom_guard": true,
+ "stream_count": 9,
+ "text_geometry_motion": 0,
+ "text_motion": 0,
+ "text_motion_count": 0,
+ "topology_draw_on": true,
+ "trace_span_reveals": [],
+ "waterfall_scanner": null
+ },
+ "width": 960
+ },
+ "report": "artifacts/investor-architecture-zh.motion.json",
+ "required_encoder": "ffmpeg/gif",
+ "review_reference_source_sha256": "52bf52e8ac0b129fcfad8dcd06e93468b3b79e29e9e0f919be80cb58046c0991",
+ "review_status": "user-approved",
+ "runtime": {
+ "encoders": [
+ "gif"
+ ],
+ "ffmpeg": "/Users/yjw/Library/Application Support/ffmpeg-downloader/ffmpeg/ffmpeg",
+ "ffprobe": "/Users/yjw/Library/Application Support/ffmpeg-downloader/ffmpeg/ffprobe",
+ "format": {
+ "gif": true
+ },
+ "node": "/Users/yjw/.local/bin/node",
+ "ok": true,
+ "renderer": {
+ "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "deterministic_timeline": true,
+ "input": "semantic-svg",
+ "module": "puppeteer-core",
+ "module_version": "25.3.0",
+ "motion_model": "connector-draw-on-with-persistent-data-flow",
+ "ok": true,
+ "presets": [
+ "memory-weave",
+ "tool-grounding",
+ "service-blueprint",
+ "memory-lifecycle",
+ "agent-orchestration",
+ "governed-runtime",
+ "token-stream",
+ "golden-circuit",
+ "review-trace",
+ "cloud-flow",
+ "event-transit",
+ "ops-pulse"
+ ],
+ "resolved_module": "/Users/yjw/.codex/skills/fireworks-tech-graph/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js",
+ "sandbox_default": "enabled"
+ },
+ "worker": "/Users/yjw/.codex/skills/fireworks-tech-graph/scripts/svg2gif.js"
+ },
+ "scene_signature": {
+ "distinctive_primitives": [
+ "semantic-route-build",
+ "glass-handoff-rail",
+ "glass-task-capsule",
+ "coordinator-halo"
+ ],
+ "name": "glassmorphism-multi-agent-task-capsules"
+ },
+ "seamless_loop_contract": "75 frames or fewer: every raster is unique; more than 75 frames: non-adjacent repeats are allowed within the full-opacity interval, plus the sole intentional reset-boundary exception at reset_start with opacity 1.00, with at least 75 unique rasters; seam MAD must not exceed p95 adjacent-frame MAD",
+ "semantic_profile": "generic",
+ "semantics": {
+ "edges": 9,
+ "edges_without_flow": 0,
+ "motion_metadata": "explicit",
+ "nodes": 8,
+ "resolved_schedule": [
+ {
+ "edge_id": "brief",
+ "order": 0,
+ "role": "ingress",
+ "source_stroke": "#60a5fa",
+ "stage": 1
+ },
+ {
+ "edge_id": "a-delegate-research",
+ "order": 0,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "b-delegate-coding",
+ "order": 1,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "c-delegate-review",
+ "order": 2,
+ "role": "delegate",
+ "source_stroke": "#c084fc",
+ "stage": 2
+ },
+ {
+ "edge_id": "research-write",
+ "order": 0,
+ "role": "evidence",
+ "source_stroke": "#34d399",
+ "stage": 3
+ },
+ {
+ "edge_id": "coding-merge",
+ "order": 1,
+ "role": "artifact",
+ "source_stroke": "#34d399",
+ "stage": 3
+ },
+ {
+ "edge_id": "memory-merge",
+ "order": 0,
+ "role": "context",
+ "source_stroke": "#60a5fa",
+ "stage": 4
+ },
+ {
+ "edge_id": "deliver",
+ "order": 0,
+ "role": "deliver",
+ "source_stroke": "#f59e0b",
+ "stage": 5
+ },
+ {
+ "edge_id": "review-output",
+ "order": 1,
+ "role": "approval",
+ "source_stroke": "#f59e0b",
+ "stage": 5
+ }
+ ],
+ "schedule_key": "(data-motion-role, data-motion-order)",
+ "staged_edges": 9
+ },
+ "size_guidance": {
+ "artifact_target_bytes": 500000,
+ "artifact_within_target": true,
+ "warnings": []
+ },
+ "source": "artifacts/investor-architecture-zh.svg",
+ "source_checks": {
+ "composition": {
+ "details": [],
+ "ok": true
+ },
+ "geometry": {
+ "details": [],
+ "ok": true
+ },
+ "markers": {
+ "details": [],
+ "ok": true
+ },
+ "xml": {
+ "details": [],
+ "ok": true
+ }
+ },
+ "source_integrity": {
+ "sha256": "81064c6a4d13399c3a4649f65b661839f851bb618453befe0653e8297f7b1607",
+ "sha256_after": "81064c6a4d13399c3a4649f65b661839f851bb618453befe0653e8297f7b1607",
+ "sha256_before": "81064c6a4d13399c3a4649f65b661839f851bb618453befe0653e8297f7b1607",
+ "unchanged": true
+ },
+ "source_sha256": "81064c6a4d13399c3a4649f65b661839f851bb618453befe0653e8297f7b1607",
+ "style_contract": {
+ "approval_recorded_on": "2026-07-16",
+ "scope": "signature-speed-path-geometry-and-construction-schedule",
+ "source_policy": "semantic-schedule-and-geometry-not-exact-byte-hash",
+ "status": "user-approved"
+ },
+ "style_contract_status": "user-approved",
+ "style_id": 5,
+ "timing_revision": {
+ "approved_at": "2026-07-17",
+ "baseline": {
+ "duration_seconds": 3.75,
+ "frame_count": 75
+ },
+ "candidate": {
+ "added_full_opacity_frames": [
+ 70,
+ 109
+ ],
+ "duration_seconds": 5.75,
+ "frame_count": 115
+ },
+ "id": "+2s-settled-flow",
+ "only_pending_item": false,
+ "status": "user-approved"
+ },
+ "visual_theme": "Glassmorphism",
+ "width": 960
+}
diff --git a/assets/diagrams/investor-research-architecture-zh-CN.source.json b/assets/diagrams/investor-research-architecture-zh-CN.source.json
new file mode 100644
index 0000000..96cc7d5
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-zh-CN.source.json
@@ -0,0 +1,51 @@
+{
+ "schema_version": 1,
+ "mode": "agent",
+ "template_type": "agent",
+ "style": 5,
+ "motion_scene": "agent-orchestration",
+ "quality_profile": "showcase",
+ "width": 960,
+ "height": 700,
+ "title": "从证据到可支持结论",
+ "subtitle": "冻结事实 · 校验命题 · 发布可靠结论 · 其余进入审计层",
+ "containers": [
+ { "id": "mission", "x": 40, "y": 110, "width": 880, "height": 110, "label": "你的投资问题", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" },
+ { "id": "specialists", "x": 40, "y": 270, "width": 880, "height": 140, "label": "证据与命题编译器", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" },
+ { "id": "delivery", "x": 40, "y": 450, "width": 880, "height": 140, "label": "发布输出", "stroke": "rgba(255,255,255,0.20)", "fill": "rgba(255,255,255,0.05)" }
+ ],
+ "nodes": [
+ { "id": "request", "kind": "speech", "x": 60, "y": 146, "width": 160, "height": 54, "label": "投资问题", "sublabel": "标的 · 日期 · 关注点", "fill": "rgba(255,255,255,0.12)", "stroke": "rgba(255,255,255,0.35)", "flat": true },
+ { "id": "coordinator", "kind": "double_rect", "x": 390, "y": 146, "width": 200, "height": 54, "type_label": "研究路由", "label": "选择证据路径", "fill": "rgba(255,255,255,0.12)", "stroke": "#c084fc" },
+ { "id": "research", "kind": "rect", "x": 80, "y": 330, "width": 180, "height": 60, "type_label": "冻结", "label": "校验证据", "fill": "rgba(255,255,255,0.12)", "stroke": "#60a5fa" },
+ { "id": "coding", "kind": "rect", "x": 390, "y": 330, "width": 200, "height": 60, "type_label": "模型", "label": "正向与逆向估值", "fill": "rgba(255,255,255,0.12)", "stroke": "#f59e0b" },
+ { "id": "review", "kind": "rect", "x": 700, "y": 330, "width": 180, "height": 60, "type_label": "命题", "label": "划分支持状态", "fill": "rgba(255,255,255,0.12)", "stroke": "#34d399" },
+ { "id": "memory", "kind": "cylinder", "x": 80, "y": 510, "width": 180, "height": 60, "label": "证据清单", "sublabel": "来源 · 日期 · 口径", "fill": "rgba(255,255,255,0.08)", "stroke": "#34d399" },
+ { "id": "synthesis", "kind": "double_rect", "x": 390, "y": 510, "width": 200, "height": 60, "type_label": "门控", "label": "发布受支持命题", "fill": "rgba(255,255,255,0.12)", "stroke": "#c084fc" },
+ { "id": "response", "kind": "speech", "x": 700, "y": 510, "width": 180, "height": 60, "label": "报告 + 审计", "sublabel": "条件 · 失效条件", "fill": "rgba(255,255,255,0.12)", "stroke": "#f59e0b", "flat": true }
+ ],
+ "arrows": [
+ { "id": "brief", "source": "request", "target": "coordinator", "source_port": "right", "target_port": "left", "flow": "read", "motion_role": "ingress", "motion_stage": 1, "motion_order": 0, "label": "明确问题" },
+ { "id": "a-delegate-research", "source": "coordinator", "target": "research", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 0, "label": "查事实" },
+ { "id": "b-delegate-coding", "source": "coordinator", "target": "coding", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 1, "label": "建模型" },
+ { "id": "c-delegate-review", "source": "coordinator", "target": "review", "source_port": "bottom", "target_port": "top", "flow": "control", "motion_role": "delegate", "motion_stage": 2, "motion_order": 2, "label": "编译命题" },
+ { "id": "research-write", "source": "research", "target": "memory", "source_port": "bottom", "target_port": "top", "flow": "write", "motion_role": "evidence", "motion_stage": 3, "motion_order": 0, "label": "保留证据" },
+ { "id": "memory-merge", "source": "memory", "target": "synthesis", "source_port": "right", "target_port": "left", "flow": "read", "motion_role": "context", "motion_stage": 4, "motion_order": 0, "label": "证据编号" },
+ { "id": "coding-merge", "source": "coding", "target": "synthesis", "source_port": "bottom", "target_port": "top", "flow": "write", "motion_role": "artifact", "motion_stage": 3, "motion_order": 1, "label": "模型命题" },
+ { "id": "review-output", "source": "review", "target": "response", "source_port": "bottom", "target_port": "top", "flow": "feedback", "motion_role": "approval", "motion_stage": 5, "motion_order": 1, "label": "仅审计" },
+ { "id": "deliver", "source": "synthesis", "target": "response", "source_port": "right", "target_port": "left", "flow": "feedback", "motion_role": "deliver", "motion_stage": 5, "motion_order": 0, "label": "发布正文" }
+ ],
+ "legend_orientation": "horizontal",
+ "legend_x": 48,
+ "legend_y": 625,
+ "legend_locked": true,
+ "legend": [
+ { "flow": "read", "label": "问题与事实" },
+ { "flow": "control", "label": "研究分工" },
+ { "flow": "write", "label": "证据与模型" },
+ { "flow": "feedback", "label": "复核后输出" }
+ ],
+ "footer": "普通缺口只过滤或缩窄命题 · 未支持命题仅进审计层 · 不自动交易",
+ "footer_x": 48,
+ "footer_y": 680
+}
diff --git a/assets/diagrams/investor-research-architecture-zh-CN.svg b/assets/diagrams/investor-research-architecture-zh-CN.svg
new file mode 100644
index 0000000..c63f85c
--- /dev/null
+++ b/assets/diagrams/investor-research-architecture-zh-CN.svg
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 从证据到可支持结论
+ 冻结事实 · 校验命题 · 发布可靠结论 · 其余进入审计层
+
+
+ 你的投资问题
+
+
+
+
+ 证据与命题编译器
+
+
+
+
+ 发布输出
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 投资问题
+ 标的 · 日期 · 关注点
+
+
+
+
+ 研究路由
+ 选择证据路径
+
+
+
+ 冻结
+ 校验证据
+
+
+
+ 模型
+ 正向与逆向估值
+
+
+
+ 命题
+ 划分支持状态
+
+
+
+
+
+
+
+ 证据清单
+ 来源 · 日期 · 口径
+
+
+
+
+ 门控
+ 发布受支持命题
+
+
+
+ 报告 + 审计
+ 条件 · 失效条件
+
+
+
+ 明确问题
+
+
+
+ 查事实
+
+
+
+ 建模型
+
+
+
+ 编译命题
+
+
+
+ 保留证据
+
+
+
+ 证据编号
+
+
+
+ 模型命题
+
+
+
+ 仅审计
+
+
+
+ 发布正文
+
+
+
+
+ 问题与事实
+
+ 研究分工
+
+ 证据与模型
+
+ 复核后输出
+
+
+
\ No newline at end of file
diff --git a/assets/investor-research-architecture-en.gif b/assets/investor-research-architecture-en.gif
index ee62557..b5e8c9f 100644
Binary files a/assets/investor-research-architecture-en.gif and b/assets/investor-research-architecture-en.gif differ
diff --git a/assets/investor-research-architecture-zh-CN.gif b/assets/investor-research-architecture-zh-CN.gif
index c2f9b3d..6d877ee 100644
Binary files a/assets/investor-research-architecture-zh-CN.gif and b/assets/investor-research-architecture-zh-CN.gif differ
diff --git a/assets/social-preview.png b/assets/social-preview.png
index 8a50d22..5421639 100644
Binary files a/assets/social-preview.png and b/assets/social-preview.png differ
diff --git a/claude-commands/research-workspace.md b/claude-commands/research-workspace.md
index d2775f6..650b603 100644
--- a/claude-commands/research-workspace.md
+++ b/claude-commands/research-workspace.md
@@ -13,9 +13,9 @@ Run:
stock-analysis --market research --symbol [--asset-type company|fund]
```
-股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,并输出证据驱动的机构报告。
+股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,最终报告只发布通过离散证据规则的 publishable_claims,普通缺口与未发布命题保留在四类审计 JSON。
-Always preserve Evidence Pack source events and state missing evidence explicitly.
+For research-workspace investor reports, use only publishable_claims. Keep ordinary missing evidence and unpublished questions in evidence_manifest.json, claim_ledger.json, coverage_report.json, and unpublished_claims.json; never turn absence of evidence into a bearish, neutral, conservative, or wait-and-see conclusion.
If Company Evidence marks agent_primary_evidence_reach as recommended, invoke the bundled
stock-analysis-primary-evidence-reach Skill, follow primary_evidence_requests, and rerun with
--primary-evidence-file. Agent Reach is optional because the bundled fallback can use host web/PDF tools.
diff --git a/codex-prompts/research-workspace.md b/codex-prompts/research-workspace.md
index 07aff98..0df48c6 100644
--- a/codex-prompts/research-workspace.md
+++ b/codex-prompts/research-workspace.md
@@ -13,9 +13,9 @@ Run:
stock-analysis --market research --symbol [--asset-type company|fund]
```
-股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,并输出证据驱动的机构报告。
+股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,最终报告只发布通过离散证据规则的 publishable_claims,普通缺口与未发布命题保留在四类审计 JSON。
-Always preserve Evidence Pack source events and state missing evidence explicitly.
+For research-workspace investor reports, use only publishable_claims. Keep ordinary missing evidence and unpublished questions in evidence_manifest.json, claim_ledger.json, coverage_report.json, and unpublished_claims.json; never turn absence of evidence into a bearish, neutral, conservative, or wait-and-see conclusion.
If Company Evidence marks agent_primary_evidence_reach as recommended, invoke the bundled
stock-analysis-primary-evidence-reach Skill, follow primary_evidence_requests, and rerun with
--primary-evidence-file. Agent Reach is optional because the bundled fallback can use host web/PDF tools.
diff --git a/codex-skills/research-workspace/SKILL.md b/codex-skills/research-workspace/SKILL.md
index 7c7c0af..2e6bd11 100644
--- a/codex-skills/research-workspace/SKILL.md
+++ b/codex-skills/research-workspace/SKILL.md
@@ -13,9 +13,9 @@ Run:
stock-analysis --market research --symbol [--asset-type company|fund]
```
-股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,并输出证据驱动的机构报告。
+股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence;lens 与 committee 消费同一 snapshot_id,最终报告只发布通过离散证据规则的 publishable_claims,普通缺口与未发布命题保留在四类审计 JSON。
-Always preserve Evidence Pack source events and state missing evidence explicitly.
+For research-workspace investor reports, use only publishable_claims. Keep ordinary missing evidence and unpublished questions in evidence_manifest.json, claim_ledger.json, coverage_report.json, and unpublished_claims.json; never turn absence of evidence into a bearish, neutral, conservative, or wait-and-see conclusion.
If Company Evidence marks agent_primary_evidence_reach as recommended, invoke the bundled
stock-analysis-primary-evidence-reach Skill, follow primary_evidence_requests, and rerun with
--primary-evidence-file. Agent Reach is optional because the bundled fallback can use host web/PDF tools.
diff --git a/promo/demo-video/README.md b/promo/demo-video/README.md
index fc704ac..613f5de 100644
--- a/promo/demo-video/README.md
+++ b/promo/demo-video/README.md
@@ -18,4 +18,4 @@ npm run render
The demo intentionally uses no voiceover or music, so it works in muted social feeds and can be localized by editing the scene copy in `src/video.jsx`.
-The v4.15 cut covers A/HK/US/JP/KR routing, session-aware evidence, SEC filing-date XBRL, issuer-primary reach, local-currency liquidity, portfolio correlation and FX attribution, then carries the verified evidence into forward/reverse valuation and committee review.
+The v4.16 cut carries A/HK/US/JP/KR evidence through forward/reverse valuation, deterministic claim extraction, discrete publication rules, audit artifacts, and committee review. Ordinary missing evidence filters or narrows a research claim instead of becoming an investment direction.
diff --git a/promo/demo-video/out/stock-analysis-demo-en.mp4 b/promo/demo-video/out/stock-analysis-demo-en.mp4
index e3ee9f3..57ec47a 100644
Binary files a/promo/demo-video/out/stock-analysis-demo-en.mp4 and b/promo/demo-video/out/stock-analysis-demo-en.mp4 differ
diff --git a/promo/demo-video/out/stock-analysis-demo-zh-CN.mp4 b/promo/demo-video/out/stock-analysis-demo-zh-CN.mp4
index fa13510..ca55dc3 100644
Binary files a/promo/demo-video/out/stock-analysis-demo-zh-CN.mp4 and b/promo/demo-video/out/stock-analysis-demo-zh-CN.mp4 differ
diff --git a/promo/demo-video/package.json b/promo/demo-video/package.json
index 9e0866e..614d4b5 100644
--- a/promo/demo-video/package.json
+++ b/promo/demo-video/package.json
@@ -4,7 +4,8 @@
"version": "1.0.0",
"scripts": {
"studio": "remotion studio src/index.jsx",
- "still": "remotion still src/index.jsx StockAnalysisDemo out/preview-en.png --frame=1050 --scale=0.5 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' && remotion still src/index.jsx StockAnalysisDemoZh out/preview-zh-CN.png --frame=1050 --scale=0.5 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'",
+ "still": "remotion still src/index.jsx StockAnalysisDemo out/preview-en.png --frame=1710 --scale=0.5 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' && remotion still src/index.jsx StockAnalysisDemoZh out/preview-zh-CN.png --frame=1710 --scale=0.5 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'",
+ "social": "remotion still src/index.jsx SocialPreview ../../assets/social-preview.png --frame=0 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'",
"render": "npm run render:en && npm run render:zh",
"render:en": "env -u ALL_PROXY -u HTTPS_PROXY -u HTTP_PROXY -u all_proxy -u https_proxy -u http_proxy remotion render src/index.jsx StockAnalysisDemo out/stock-analysis-demo-en.mp4 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' --concurrency=1",
"render:zh": "env -u ALL_PROXY -u HTTPS_PROXY -u HTTP_PROXY -u all_proxy -u https_proxy -u http_proxy remotion render src/index.jsx StockAnalysisDemoZh out/stock-analysis-demo-zh-CN.mp4 --browser-executable='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' --concurrency=1",
diff --git a/promo/demo-video/src/root.jsx b/promo/demo-video/src/root.jsx
index c73b121..6f20280 100644
--- a/promo/demo-video/src/root.jsx
+++ b/promo/demo-video/src/root.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import {Composition} from 'remotion';
-import {StockAnalysisDemo} from './video';
+import {SocialPreview, StockAnalysisDemo} from './video';
export const Root = () => (
<>
@@ -22,5 +22,13 @@ export const Root = () => (
height={1080}
defaultProps={{language: 'zh'}}
/>
+
>
);
diff --git a/promo/demo-video/src/video.jsx b/promo/demo-video/src/video.jsx
index d4e868e..e5963d8 100644
--- a/promo/demo-video/src/video.jsx
+++ b/promo/demo-video/src/video.jsx
@@ -176,12 +176,12 @@ const Brand = ({zh}) => {
stock-analysis
- {zh ? '核验事实 建模经营 再解释市场定价' : 'Verify facts. Model the business. Explain the price.'}
+ {zh ? '核验事实 建模经营 只发布受支持命题' : 'Verify facts. Model the business. Publish supported claims.'}
{zh ? '全球行情' : 'Global markets'}
{zh ? '个股' : 'Stocks'}
{zh ? '基金' : 'Funds'}
- {zh ? '15种投资框架' : '15 investment frameworks'}
+ {zh ? '命题级发布门控' : 'Claim-level publication'}
@@ -253,8 +253,8 @@ const Evidence = ({zh}) => {
const {fps} = useVideoConfig();
const coverage = zh ? ['A股', '港股', '美股', '日股', '韩股', '基金/组合'] : ['A-shares', 'Hong Kong', 'US stocks', 'Japan', 'Korea', 'Funds/portfolios'];
const evidenceTypes = zh
- ? ['交易时段与日历', '多源量价', 'SEC与年报一手财务', 'Agent一手证据补齐', '本币成交额与60日波动', '组合相关性与汇率归因', '正逆向估值', '动态投委会']
- : ['Sessions and calendars', 'Multi-source price/volume', 'SEC and annual reports', 'Agent primary-evidence reach', 'Local ADV and 60d volatility', 'Correlation and FX attribution', 'Forward/reverse valuation', 'Dynamic committee'];
+ ? ['交易时段与日历', '多源量价', 'SEC与年报一手财务', 'Agent一手证据补齐', '本币成交额与60日波动', '组合相关性与汇率归因', '正逆向估值', '命题发布与审计']
+ : ['Sessions and calendars', 'Multi-source price/volume', 'SEC and annual reports', 'Agent primary-evidence reach', 'Local ADV and 60d volatility', 'Correlation and FX attribution', 'Forward/reverse valuation', 'Claim publication and audit'];
return (
@@ -275,7 +275,7 @@ const Evidence = ({zh}) => {
- {zh ? '多源自动切换 缺失项明确保留' : 'Multi-source routing · Explicit gaps'}
+ {zh ? '普通缺口过滤或缩窄命题 不改变投资方向' : 'Ordinary gaps filter or narrow claims—not direction'}
@@ -330,21 +330,21 @@ const AgentFlow = ({zh}) => {
const {fps} = useVideoConfig();
const flows = zh
? [
- ['识别市场与时段', '交易日历和可用性状态', '尚未产生不等于数据源故障'],
- ['取得结构化证据', '行情 财务 流动性 汇率', '日期口径与来源可审计'],
- ['补发行人原文', '公司IR 交易所 监管机构', '缺失仍保留 不用摘要冒充'],
- ['形成判断', '正逆向估值与投委会', '反证 条件与持续跟踪'],
+ ['冻结研究证据', '来源 日期 期间与口径', '全部 lens 读取同一快照'],
+ ['提取结构化命题', '陈述 引证 条件与失效条件', '五种离散支持状态'],
+ ['执行发布门控', '发布 缩窄 阻断动作或报告', '普通缺口不获得方向权重'],
+ ['生成双层输出', '投委会与既有报告骨架', '投资者正文 + 四个审计 JSON'],
]
: [
- ['Resolve market and session', 'calendar and availability state', 'not-yet-created is not source failure'],
- ['Gather structured evidence', 'quotes financials liquidity and FX', 'auditable date scope and source'],
- ['Reach issuer originals', 'IR exchange and regulator', 'keep gaps when originals are absent'],
- ['Form the view', 'forward/reverse valuation and committee', 'counter-evidence conditions and tracking'],
+ ['Freeze research evidence', 'source date period and scope', 'every lens reads one snapshot'],
+ ['Extract structured claims', 'statement citations conditions invalidators', 'five discrete support states'],
+ ['Apply publication gates', 'publish narrow block action or report', 'ordinary gaps get no directional weight'],
+ ['Render two output layers', 'committee and existing report spine', 'investor Markdown + four audit JSON files'],
];
return (
-
{zh ? '从全球证据 到可持续验证的判断' : 'From global evidence to a continuously testable view'}
+
{zh ? '能证明什么就发布什么 其余进入审计层' : 'Publish what the evidence supports. Audit the rest.'}
{flows.map(([question, evidence, report], i) => {
const p = spring({frame: frame - i * 18, fps, config: {damping: 18}});
@@ -375,14 +375,14 @@ const Close = ({zh}) => {
{zh ? '正向回答 公司能赚多少' : 'Forward: what can the business earn?'}
{zh ? '逆向回答 股价已经相信多少' : 'Reverse: what does the price already believe?'}
- {zh ? '真正的机会 藏在两者之间' : 'The investable question lives in the gap.'}
+ {zh ? '可靠结论 还必须通过命题发布门控' : 'Reliable conclusions must also pass the publication gate.'}
$ scripts/install-agent-entrypoints.sh codex
github.com/AdvancingTitans/stock-analysis
- {zh ? 'A/HK/US/JP/KR' : 'A/HK/US/JP/KR'} {zh ? 'SEC+发行人原文' : 'SEC + issuer originals'} {zh ? '正逆向估值与跟踪' : 'Valuation + monitoring'}
+ {zh ? 'A/HK/US/JP/KR' : 'A/HK/US/JP/KR'} {zh ? '正逆向估值' : 'Forward + reverse valuation'} {zh ? '命题发布与审计' : 'Claim publication + audit'}
@@ -405,3 +405,51 @@ export const StockAnalysisDemo = ({language = 'en'}) => {
);
};
+
+export const SocialPreview = () => (
+
+
+
+
OPEN SOURCE · MIT
+
+ stock-analysis
+
+
+ Evidence-first investment research for agents and humans.
+
+
+ Global stocks · funds · portfolios Forward/reverse valuation · supported-claim publication
+
+
+ A/HK/US/JP/KR
+ Deterministic CLI
+
+
+
+
RESEARCH COMPILER
+ {[
+ ['01', 'Freeze evidence', 'source · date · period · scope'],
+ ['02', 'Compile claims', 'citations · conditions · invalidators'],
+ ['03', 'Apply publication gates', 'publish · narrow · block action/report'],
+ ].map(([index, title, detail], i) => (
+
+
+ {index}
+ {title}
+
+
{detail}
+
+ ))}
+
+
+
PUBLISH
+
Investor report
+
+
+
AUDIT
+
Four JSON artifacts
+
+
+
+
+);
diff --git a/pyproject.toml b/pyproject.toml
index deb8f27..9b51e9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "stock-analysis"
-version = "4.15.0"
-description = "Evidence-driven global stock recap skill with portfolio analysis and resilient multi-source routing."
+version = "4.16.0"
+description = "Evidence-first research CLI and Agent workflow for global stocks, funds, and portfolios."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
diff --git a/schemas/company-committee.schema.json b/schemas/company-committee.schema.json
index 74fd884..5273b9f 100644
--- a/schemas/company-committee.schema.json
+++ b/schemas/company-committee.schema.json
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "stock-analysis Company Committee Synthesis",
"type": "object",
- "required": ["committee_id", "evidence_snapshot_id", "opinion_ids", "members", "consensus", "disagreements", "risk_vetoes", "action", "action_conditions", "research_question", "evidence_consumption_audit"],
+ "required": ["committee_id", "evidence_snapshot_id", "opinion_ids", "members", "publishable_claims", "report_blockers", "unpublished_questions", "consensus", "disagreements", "risk_vetoes", "action", "publication_status", "safety_gate", "action_conditions", "research_question", "evidence_consumption_audit"],
"properties": {
"committee_id": {"type": "string"},
"evidence_snapshot_id": {"type": "string", "pattern": "^sha256:"},
@@ -11,7 +11,25 @@
"consensus": {"type": "array", "items": {"type": "string"}},
"disagreements": {"type": "array", "items": {"type": "string"}},
"risk_vetoes": {"type": "array", "items": {"type": "string"}},
- "action": {"enum": ["observe", "manual_review"]},
+ "action": {"enum": ["manual_review", "block_report"]},
+ "publication_status": {"enum": ["publish", "block_action", "block_report"]},
+ "publishable_claims": {
+ "type": "array",
+ "items": {"$ref": "research-claim.schema.json"}
+ },
+ "report_blockers": {
+ "type": "array",
+ "items": {"$ref": "research-claim.schema.json"}
+ },
+ "unpublished_questions": {"type": "array", "items": {"type": "object"}},
+ "safety_gate": {
+ "type": "object",
+ "required": ["decision", "issues"],
+ "properties": {
+ "decision": {"enum": ["publish", "block_action", "block_report"]},
+ "issues": {"type": "array", "items": {"type": "object"}}
+ }
+ },
"action_conditions": {"type": "array", "items": {"type": "string"}},
"research_question": {"type": "string"},
"evidence_consumption_audit": {
diff --git a/schemas/company-lens-opinion.schema.json b/schemas/company-lens-opinion.schema.json
index 88c5bd8..28f5ad8 100644
--- a/schemas/company-lens-opinion.schema.json
+++ b/schemas/company-lens-opinion.schema.json
@@ -2,12 +2,12 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "stock-analysis Company Lens Opinion",
"type": "object",
- "required": ["opinion_id", "lens_id", "evidence_snapshot_id", "status", "required_modules", "supporting_evidence_ids", "counter_evidence_ids", "metric_analyses", "research_question", "confidence", "conclusion"],
+ "required": ["opinion_id", "lens_id", "evidence_snapshot_id", "status", "required_modules", "supporting_evidence_ids", "counter_evidence_ids", "metric_analyses", "publishable_claims", "report_blockers", "unpublished_questions", "research_question", "question_modules", "confidence", "conclusion"],
"properties": {
"opinion_id": {"type": "string"},
"lens_id": {"type": "string"},
"evidence_snapshot_id": {"type": "string", "pattern": "^sha256:"},
- "status": {"enum": ["review_ready", "evidence_insufficient"]},
+ "status": {"const": "review_ready"},
"required_modules": {"type": "array", "items": {"type": "string"}},
"supporting_evidence_ids": {"type": "array", "items": {"type": "string"}},
"counter_evidence_ids": {"type": "array", "items": {"type": "string"}},
@@ -26,7 +26,17 @@
}
}
},
+ "publishable_claims": {
+ "type": "array",
+ "items": {"$ref": "research-claim.schema.json"}
+ },
+ "report_blockers": {
+ "type": "array",
+ "items": {"$ref": "research-claim.schema.json"}
+ },
+ "unpublished_questions": {"type": "array", "items": {"type": "object"}},
"research_question": {"type": "string"},
+ "question_modules": {"type": "array", "items": {"type": "string"}},
"confidence": {"enum": ["low", "medium", "high"]},
"conclusion": {"type": "string"}
}
diff --git a/schemas/research-claim.schema.json b/schemas/research-claim.schema.json
new file mode 100644
index 0000000..b65cf60
--- /dev/null
+++ b/schemas/research-claim.schema.json
@@ -0,0 +1,110 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "stock-analysis Research Claim",
+ "type": "object",
+ "required": [
+ "claim_id",
+ "claim",
+ "direction",
+ "scope",
+ "applicable_period",
+ "evidence_ids",
+ "claim_status",
+ "conditions",
+ "invalidators",
+ "missing_evidence",
+ "missing_evidence_effect",
+ "primary_source_ids",
+ "secondary_source_ids",
+ "has_reproducible_calculation",
+ "calculation_input_evidence_ids",
+ "report_blocking_reasons",
+ "publication_decision"
+ ],
+ "properties": {
+ "claim_id": {"type": "string"},
+ "claim": {"type": "string"},
+ "direction": {"enum": ["bullish", "bearish", "neutral"]},
+ "scope": {"type": "string"},
+ "applicable_period": {"type": "string"},
+ "evidence_ids": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {"type": "string", "minLength": 1}
+ },
+ "claim_status": {
+ "enum": [
+ "strongly_supported",
+ "supported",
+ "unsupported",
+ "speculative",
+ "conflicted_unresolved"
+ ]
+ },
+ "conditions": {"type": "array", "minItems": 1, "items": {"type": "string"}},
+ "invalidators": {"type": "array", "minItems": 1, "items": {"type": "string"}},
+ "missing_evidence": {"type": "array", "items": {"type": "string"}},
+ "missing_evidence_effect": {
+ "enum": ["no_material_effect", "narrows_scope", "blocks_claim", "blocks_action"]
+ },
+ "primary_source_ids": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+@sha256:[0-9a-f]{64}$"
+ }
+ },
+ "secondary_source_ids": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+@sha256:[0-9a-f]{64}$"
+ }
+ },
+ "primary_source_count": {"type": "integer", "minimum": 0, "readOnly": true},
+ "secondary_source_count": {"type": "integer", "minimum": 0, "readOnly": true},
+ "has_reproducible_calculation": {"type": "boolean"},
+ "calculation_input_evidence_ids": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {"type": "string", "minLength": 1}
+ },
+ "report_blocking_reasons": {
+ "type": "array",
+ "items": {"type": "string", "minLength": 1}
+ },
+ "publication_decision": {
+ "enum": ["publish", "publish_narrowed", "audit_only", "block_action", "block_report"]
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "claim_status": {"enum": ["strongly_supported", "supported"]}
+ },
+ "required": ["claim_status"]
+ },
+ "then": {
+ "properties": {
+ "evidence_ids": {"minItems": 1}
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "has_reproducible_calculation": {"const": true}
+ },
+ "required": ["has_reproducible_calculation"]
+ },
+ "then": {
+ "properties": {
+ "calculation_input_evidence_ids": {"minItems": 1}
+ }
+ }
+ }
+ ]
+}
diff --git a/schemas/research-workspace.schema.json b/schemas/research-workspace.schema.json
index ac720a9..5c1f745 100644
--- a/schemas/research-workspace.schema.json
+++ b/schemas/research-workspace.schema.json
@@ -7,7 +7,8 @@
"schema_version": {"const": "1.0"},
"symbol": {"type": "string"},
"trade_date": {"type": "string", "pattern": "^[0-9]{8}$"},
- "status": {"enum": ["evidence_insufficient", "ready_for_analysis"]},
+ "status": {"enum": ["evidence_insufficient", "ready_for_analysis", "action_blocked", "blocked_report"]},
+ "publication_status": {"enum": ["publish", "block_action", "block_report"]},
"stages": {"type": "object"},
"evidence_snapshot": {"type": "object", "required": ["coverage", "available_modules", "missing_modules", "sha256", "snapshot_id", "committee_id"]},
"artifacts": {"type": "object"}
diff --git a/scripts/sync_agent_entrypoints.py b/scripts/sync_agent_entrypoints.py
index d1d8657..ead40cf 100755
--- a/scripts/sync_agent_entrypoints.py
+++ b/scripts/sync_agent_entrypoints.py
@@ -13,6 +13,14 @@
def rendered(command: dict[str, str], target: str) -> str:
title = command["id"]
+ evidence_policy = (
+ "For research-workspace investor reports, use only publishable_claims. Keep ordinary missing "
+ "evidence and unpublished questions in evidence_manifest.json, claim_ledger.json, "
+ "coverage_report.json, and unpublished_claims.json; never turn absence of evidence into a "
+ "bearish, neutral, conservative, or wait-and-see conclusion."
+ if title == "research-workspace"
+ else "Always preserve Evidence Pack source events and state missing evidence explicitly."
+ )
body = f"""---
name: {title}
description: {command['summary']}
@@ -30,7 +38,7 @@ def rendered(command: dict[str, str], target: str) -> str:
{command['output']}
-Always preserve Evidence Pack source events and state missing evidence explicitly.
+{evidence_policy}
If Company Evidence marks agent_primary_evidence_reach as recommended, invoke the bundled
stock-analysis-primary-evidence-reach Skill, follow primary_evidence_requests, and rerun with
--primary-evidence-file. Agent Reach is optional because the bundled fallback can use host web/PDF tools.
diff --git a/skills/stock-analysis/SKILL.md b/skills/stock-analysis/SKILL.md
index 8ad02a7..0f10084 100644
--- a/skills/stock-analysis/SKILL.md
+++ b/skills/stock-analysis/SKILL.md
@@ -2,7 +2,7 @@
name: stock-analysis
description: 全球股市深度复盘技能。用于 A股、港股、美股、日股、韩股和基金的当前行情、盘前/盘中/盘后复盘、证据驱动分析与公司研究;日韩使用免费免登录行情、独立交易日历,并在缺少一手财务时触发自包含的 primary-evidence-reach 能力。
metadata:
- version: "4.15.0"
+ version: "4.16.0"
author: "Hermes Agent + yjw"
platforms: "linux, macos, windows"
---
@@ -44,10 +44,11 @@ uv run python -m stock_analysis --market research --symbol 512480 --asset-type f
- `--market stock --symbol <代码>` 与 `--market fund --symbol <代码>` 是确定性速览入口,不触发 LLM;A股单股速览会补充东财 datacenter 已披露财务快照和 Sina 盘口价差快照,基金速览会补充公开长期业绩、前端费率、规模和基金经理画像,缺字段保留空值并提示缺口。
- `stock-review`、`earnings`、`price-move` 与 `thesis-*` 是公司场景入口:先生成独立的 C1–C8 Company Evidence Pack,绝不把 M1–M6 市场证据当作公司事实。`stock-review` 只能给出已验证事实、明确缺口和观察条件;`earnings` 只复核已披露结构化财务事实;`price-move` 区分价格/量价/新闻样本与未解释部分,不能把相关性断言为主因。
- `thesis-create` 和 `thesis-review` 只在用户明确请求时读写 `~/.stock_analysis/theses`(可由 `STOCK_ANALYSIS_THESIS_DIR` 覆盖)。论文保存支持事实、反证和失效条件的结构,自动 diff 只比较结构化 Evidence,不能替代一手披露复核。
-- `research` 在用户明确请求深度股票或基金研究时创建或恢复 Research Workspace。默认路径为 `~/.stock_analysis/research///`,可由 `STOCK_ANALYSIS_RESEARCH_DIR` 或 `--workspace-dir` 覆盖;股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence。各自 lens opinions 与 committee synthesis 必须消费同一个 `snapshot_id`,并保存研究计划、证据摘要、决策 memo 和机构报告。人工修改过的阶段文件不得被静默覆盖。
+- `research` 在用户明确请求深度股票或基金研究时创建或恢复 Research Workspace。默认路径为 `~/.stock_analysis/research///`,可由 `STOCK_ANALYSIS_RESEARCH_DIR` 或 `--workspace-dir` 覆盖;股票冻结 C1–C8 Company Evidence,基金冻结 F1–F8 Fund Evidence。各自 lens opinions 与 committee synthesis 必须消费同一个 `snapshot_id`,并保存研究计划、证据摘要、决策 memo 和机构报告。人工修改过的阶段文件不得被静默覆盖。每位 lens 将命题隔离为 `publishable_claims` 与 `unpublished_questions`,committee 只能综合前者。
- `--asset-type auto|company|fund` 控制 `research` 路由;`auto` 识别常见 A股场内基金前缀。Fund Research 独立评估产品契约、指数暴露、集中度、业绩、折溢价、tracking quality、风险和运营,不把 Company C1–C8 套到 ETF,也不以 ETF 单价替代底层成分估值。
- Research 机构报告沿用中文投委会骨架:执行摘要 → 核心矛盾/产品契约 → 财务或持仓 → 资本配置或业绩风险 → 估值与交易实现 → 投委会审议 → 风险催化 → 条件化动作。`Coverage`、`Missing`、`manual_review`、快照 ID 与内部审计结果只进入 JSON/Workspace,不得出现在用户报告或替代投资分析。
-- 机构报告不得把模块覆盖率直接写成“证据不足,维持观察”。执行摘要必须综合已验证的质量、增长、估值和风险事实;尚未覆盖的内容只在确实影响结论时改写为用户可理解的研究边界或后续跟踪重点。Company 估值可以用已披露年度 EPS/BPS 生成静态 PE/PB proxy 与敏感性情景,但必须明确不是目标价。
+- `research` 机构报告采用离散命题发布规则:`strongly_supported` 与 `supported` 可以进入正文,`unsupported`、`speculative`、`conflicted_unresolved` 只进入审计产物。缺失证据只影响命题发布范围,不得被解释为 bearish、neutral、保守、观望或等待信号;范围可缩窄时使用 `missing_evidence_effect=narrows_scope`。Company 估值可以用已披露年度 EPS/BPS 生成静态 PE/PB proxy 与敏感性情景,但必须明确不是目标价。
+- `research` Workspace 固定保留 `evidence_manifest.json`、`claim_ledger.json`、`coverage_report.json`、`unpublished_claims.json`。价格、总市值或流动性缺失只阻断估值/执行行动;身份、口径完整性、一手冲突、前视偏差或完全没有可发布命题才阻断整份报告。
- Company Evidence 可从财务历史派生净利率、经营现金转化和年度毛利稳定性,作为商业经济性与护城河代理;一手年报事实通过 PDF 文本抽取与 JSON 规则目录进入 C1–C8,新增发行人不得在 Python 报告逻辑中硬编码数值。所有派生项必须保留公式和状态。
- Company C6 必须先用总市值除以明确估值倍数反推市场隐含净利润;提供 `--expectations-file` 时,再按“出货量×ASP→收入→净利润→分部价值”生成正向产品线/SOTP 模型,并输出预期差、市值剩余价值和期权业务所需收入利润。用户假设不得升级为公司事实;内部配套产品只能归入一个分部,负剩余价值不得归零。
- Company 研究先审计用户前提,再处理证据冲突:只有报告期、会计口径和范围可比时才按来源层级与新鲜度仲裁。C8 的关键假设应绑定指标、基准、下次检查日期和观点变化条件,而不是只列宽泛风险。
@@ -55,7 +56,7 @@ uv run python -m stock_analysis --market research --symbol 512480 --asset-type f
- 股票、基金与持仓场景统一调用交易成本情景模型:至少包含实时买卖价差、20 日平均成交额、波动率冲击、订单参与率、券商佣金假设、交易所经手费、适用的过户费/卖出印花税,以及基金管理费/托管费和折溢价观察。默认给出 10 万、100 万、500 万元三档;这是可校准情景,不得冒充用户真实成本。
- 问题驱动的 6 人投委会适用于 Research 与市场/持仓报告的所有 committee 入口。市场持仓中的发行人一手披露、结构化财务、基金标的指数和交易成本也要进入公共证据契约;每位入选委员必须消费所有公共指标,再按框架区分核心证据与背景证据。
- Company Evidence 对已接入源补充财报/业绩预告/快报、PE/PB/市值、融资现金流,以及公告索引中的治理和资本配置事件。东财/Futu 聚合内容仍标为 secondary;未回查交易所或公司原文时不得写成已验证的一手原文。
-- 每条 Company evidence 必须有稳定 `evidence_id` 和 `validation_status`;Company lens 只能引用冻结快照中的 ID。committee 必须拒绝混用不同 `snapshot_id` 的 opinions,只输出 `observe` 或 `manual_review`,不得自动推导仓位或买卖动作。
+- 每条 Company evidence 必须有稳定 `evidence_id` 和 `validation_status`;Company lens 只能引用冻结快照中的 ID。committee 必须拒绝混用不同 `snapshot_id` 的 opinions,只能从可发布命题形成条件化研究结论,不得自动推导仓位或买卖动作。
- `mootdx` 默认关闭;只有明确需要五档、逐笔或深度分钟 K 时才使用 `--enable-mootdx`。
- 专用能力由 `sources/mootdx_adapter.py` 执行;依赖缺失、TCP 失败或返回空数据时自动回普通腾讯/新浪报价并记录原因。
@@ -296,7 +297,7 @@ A股个股或 A股持仓若有 `STOCK.financial_snapshot` / `_meta.stock_financi
- 识别到单个专家时,单专家视角不输出机构化综合判断,也不输出“交易计划草案”“风险管理意见”“组合经理最终意见”等委员会小节。
- 单专家报告最后章节标题必须按中文名写成 `## {专家中文名}持仓建议与风险提示`;若没有完整持仓信息,可写成 `## {专家中文名}市场建议与风险提示`,但不得伪造持仓。
- 不得模仿身份声明或虚构专家发言:不要写“我是巴菲特本人”“芒格会说”,也不要编造历史人物未说过的话;可以写“按巴菲特框架看”“以芒格式反向检查”。
-- 专家框架不能覆盖数据纪律:所有结论仍必须回到 evidence、行情、成交、资金流、财务或公开信息;证据不足时明确写“证据不足,维持观察”。
+- 专家框架不能覆盖数据纪律:所有结论仍必须回到 evidence、行情、成交、资金流、财务或公开信息。`research` 中没有达到发布门槛的问题进入 `unpublished_questions`,不得生成“证据不足,维持观察”等替代结论;其他入口继续遵守各自的缺口展示契约。
- 若用户同时指定多个专家,除非明确要求 `all` 或“多专家综合”,否则只执行用户最后明确指定的一个专家;明确要求 `all`、`committee` 或“多专家综合”时,使用 `scripts/lens_registry.py` 的默认 committee 成员和各 JSON 的 `committee_role`、`committee_synthesis_rules` 做压缩综合,不输出逐轮辩论过程。
## Trading 入口与持仓完整性
@@ -394,7 +395,7 @@ A股个股或 A股持仓若有 `STOCK.financial_snapshot` / `_meta.stock_financi
- 每个模块使用 `==关键判断==`,并包含判断、证据、风险或确认条件。
- 指数、持仓、连板梯队使用 Markdown 表格。
- 精选资讯和补充数据只能作为证据、风险或观察清单,不能直接写成买入、卖出、申购、赎回或仓位信号。
-- 缺失指标不得被静默吞掉;若已尝试稳定公开源和精选资讯仍缺失,必须在对应模块自然说明“相关指标当日未披露”。
+- 非 `research` 报告中的缺失指标不得被静默吞掉;若已尝试稳定公开源和精选资讯仍缺失,必须在对应模块自然说明“相关指标当日未披露”。`research` 的普通缺口只进入四类审计 JSON,不进入投资者正文。
- 结尾必须原样包含:
`以上内容仅供参考,不构成任何投资建议。股市有风险,投资需谨慎。`
diff --git a/src/stock_analysis/__init__.py b/src/stock_analysis/__init__.py
index 7683f5e..a095640 100644
--- a/src/stock_analysis/__init__.py
+++ b/src/stock_analysis/__init__.py
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
-__version__ = "4.15.0"
+__version__ = "4.16.0"
diff --git a/src/stock_analysis/committee_selection.py b/src/stock_analysis/committee_selection.py
index 93a5062..afbc119 100644
--- a/src/stock_analysis/committee_selection.py
+++ b/src/stock_analysis/committee_selection.py
@@ -47,12 +47,49 @@
"liquidity": "流动性",
"macro": "宏观",
"risk": "风险",
+ "business model": "商业模式",
+ "market share": "市场份额",
+ "pricing power": "定价权",
+ "management team": "管理层",
+ "buyback": "回购",
+ "dividend": "分红",
+ "earnings": "盈利",
+ "revenue": "收入增长",
+ "product mandate": "产品契约",
+ "index methodology": "指数契约",
+ "holdings": "持仓",
+ "performance": "业绩",
+ "tracking error": "跟踪",
+ "premium discount": "折溢价",
+ "management fee": "管理费",
+ "catalyst": "催化",
"现金创造": "现金流",
"估值保护": "安全边际",
"公司治理": "治理",
"成交确认": "量价",
}
+COMPANY_RESEARCH_MODULE_TOPICS = {
+ "C1": ("商业模式", "生意", "客户", "产品定位", "怎么赚钱"),
+ "C2": ("财务", "现金流", "利润", "盈利", "资产负债", "自由现金流", "roe"),
+ "C3": ("增长", "景气", "盈利加速", "收入增长", "渗透率", "创新", "产业"),
+ "C4": ("护城河", "竞争", "品牌", "市场份额", "定价权", "渠道"),
+ "C5": ("管理层", "治理", "资本配置", "分红", "回购", "激励"),
+ "C6": ("估值", "安全边际", "低估", "赔率", "市值", "目标价"),
+ "C7": ("风险", "下行", "波动", "回撤", "流动性", "交易成本", "止损"),
+ "C8": ("催化", "预期差", "反身性", "政策", "跟踪", "拐点"),
+}
+FUND_RESEARCH_MODULE_TOPICS = {
+ "F1": ("产品契约", "产品定位", "指数契约", "复制", "基准"),
+ "F2": ("持仓", "成分", "暴露", "集中度", "行业", "组合"),
+ "F3": ("业绩", "收益", "趋势", "景气", "增长"),
+ "F4": ("跟踪", "折溢价", "交易", "流动性", "交易成本"),
+ "F5": ("估值", "安全边际", "低估", "底层"),
+ "F6": ("风险", "回撤", "波动", "beta", "压力"),
+ "F7": ("治理", "规模", "管理费", "基金经理", "申购", "赎回", "运营"),
+ "F8": ("催化", "调仓", "宏观", "周期", "政策", "跟踪条件"),
+}
+
def _normalize_question(question: str) -> str:
normalized = question.lower()
@@ -60,6 +97,29 @@ def _normalize_question(question: str) -> str:
return " ".join((normalized, *matched_topics))
+def relevant_research_modules(
+ research_question: str | None,
+ *,
+ asset_type: str,
+) -> tuple[str, ...]:
+ """Map an explicit research question to modules before lens aggregation."""
+
+ topics = (
+ FUND_RESEARCH_MODULE_TOPICS
+ if asset_type == "fund"
+ else COMPANY_RESEARCH_MODULE_TOPICS
+ )
+ if not research_question:
+ return tuple(topics)
+ question = _normalize_question(research_question)
+ matched = tuple(
+ module
+ for module, keywords in topics.items()
+ if any(keyword in question for keyword in keywords)
+ )
+ return matched
+
+
def select_committee(research_question: str | None, *, asset_type: str = "company") -> tuple[str, ...]:
question = _normalize_question(research_question or DEFAULT_RESEARCH_QUESTION)
if asset_type == "fund" and not research_question:
diff --git a/src/stock_analysis/company_evidence.py b/src/stock_analysis/company_evidence.py
index 3db806c..9ea97fd 100644
--- a/src/stock_analysis/company_evidence.py
+++ b/src/stock_analysis/company_evidence.py
@@ -28,6 +28,7 @@
)
from .normalize import normalize_code
from .primary_disclosures import load_issuer_primary_facts
+from .research_claims import build_evidence_integrity_audit
from .sec_filings import fetch_sec_financials
from .source_outcome import capture_source
@@ -724,6 +725,25 @@ def build_company_evidence(
"primary_evidence_requests": primary_requests,
},
}
+ result["_meta"].update(
+ build_evidence_integrity_audit(
+ sections,
+ requested_symbol=normalize_code(symbol),
+ resolved_symbol=(
+ quote.symbol
+ if quote
+ else financials.get("symbol")
+ or price_volume.get("symbol")
+ or disclosures.get("symbol")
+ or (
+ normalized
+ if any(section.get("evidence") for section in sections.values())
+ else None
+ )
+ ),
+ trade_date=trade_date,
+ )
+ )
from .company_lens import freeze_company_evidence
result["_meta"]["evidence_snapshot_id"] = freeze_company_evidence(result)["snapshot_id"]
diff --git a/src/stock_analysis/company_lens.py b/src/stock_analysis/company_lens.py
index f99cafa..87c6324 100644
--- a/src/stock_analysis/company_lens.py
+++ b/src/stock_analysis/company_lens.py
@@ -7,8 +7,23 @@
import json
from typing import Any
-from .committee_selection import DEFAULT_RESEARCH_QUESTION, select_committee
+from .committee_selection import (
+ DEFAULT_RESEARCH_QUESTION,
+ relevant_research_modules,
+ select_committee,
+)
from .lens_engine import DEFAULT_COMMITTEE_MEMBERS, LensEngine
+from .research_claims import (
+ MissingEvidenceEffect,
+ PublicationDecision,
+ ResearchClaim,
+ SupportStatus,
+ claim_source_ids,
+ evaluate_safety_gate,
+ partition_claims,
+ validate_claim_evidence_ids,
+ validated_calculation_inputs,
+)
DEFAULT_COMPANY_COMMITTEE = DEFAULT_COMMITTEE_MEMBERS
COMPANY_LENS_MODULES = {
@@ -58,7 +73,16 @@ def freeze_company_evidence(pack: dict[str, Any]) -> dict[str, Any]:
"modules": copy.deepcopy(pack["modules"]),
"_meta": {
key: copy.deepcopy((pack.get("_meta") or {}).get(key))
- for key in ("coverage", "available_modules", "missing_modules", "source_events")
+ for key in (
+ "coverage",
+ "available_modules",
+ "missing_modules",
+ "source_events",
+ "identity_validation",
+ "publication_cutoff_audit",
+ "basis_conflicts",
+ "primary_conflicts",
+ )
},
}
return {
@@ -121,6 +145,176 @@ def interpret_metric_for_lens(lens_id: str, metric: str, value: Any, module: str
return f"{rendered};{focus}({module})。"
+def _safe_float(value: Any) -> float | None:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _claim_period(item: dict[str, Any], trade_date: str) -> str:
+ return str(item.get("period") or item.get("report_date") or trade_date)
+
+
+def _claim_source_support(item: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ if str(item.get("validation_status") or "").lower() != "accepted":
+ return (), ()
+ return claim_source_ids(item)
+
+
+def _metric_claim(
+ module: str,
+ item: dict[str, Any],
+ trade_date: str,
+ evidence_by_id: dict[str, dict[str, Any]],
+) -> ResearchClaim:
+ evidence_id = str(item["evidence_id"])
+ metric = str(item["metric"])
+ primary_ids, secondary_ids = _claim_source_support(item)
+ calculation_inputs = validated_calculation_inputs(item, evidence_by_id)
+ supported = bool(primary_ids or len(set(secondary_ids)) >= 2 or calculation_inputs)
+ return ResearchClaim(
+ claim_id=_content_id("claim", {"module": module, "metric": metric, "evidence_id": evidence_id}),
+ claim=f"{_metric_text(metric, item.get('value'))}。",
+ direction="neutral",
+ scope=module,
+ evidence_ids=tuple(dict.fromkeys((evidence_id, *calculation_inputs))),
+ claim_status=SupportStatus.SUPPORTED if supported else SupportStatus.UNSUPPORTED,
+ applicable_period=_claim_period(item, trade_date),
+ conditions=("指标口径、单位和报告期与本次引用证据保持一致。",),
+ invalidators=("后续一手披露更正该指标、口径或报告期。",),
+ missing_evidence_effect=MissingEvidenceEffect.NO_MATERIAL_EFFECT,
+ primary_source_ids=primary_ids,
+ secondary_source_ids=secondary_ids,
+ calculation_input_evidence_ids=calculation_inputs,
+ )
+
+
+def _cash_conversion_claim(
+ metric_items: list[tuple[str, dict[str, Any]]], trade_date: str
+) -> ResearchClaim | None:
+ by_metric = {str(item["metric"]): (module, item) for module, item in metric_items}
+ wanted = (
+ "operating_cash_flow_yoy_pct",
+ "accounts_receivable_yoy_pct",
+ "revenue_yoy_pct",
+ )
+ if any(metric not in by_metric for metric in wanted):
+ return None
+ cash_flow = _safe_float(by_metric[wanted[0]][1].get("value"))
+ receivables = _safe_float(by_metric[wanted[1]][1].get("value"))
+ revenue = _safe_float(by_metric[wanted[2]][1].get("value"))
+ if cash_flow is None or receivables is None or revenue is None:
+ return None
+ bearish = cash_flow < 0 and receivables > revenue
+ bullish = cash_flow >= 0 and receivables <= revenue
+ if not (bearish or bullish):
+ return None
+ items = [by_metric[metric][1] for metric in wanted]
+ if any(str(item.get("validation_status") or "").lower() != "accepted" for item in items):
+ return None
+ # Plan sections 3 and 10: derived conclusions require comparable inputs.
+ periods = {_claim_period(item, trade_date) for item in items}
+ scopes = {str(item.get("scope") or "") for item in items}
+ units = {
+ (str(item.get("unit") or ""), str(item.get("currency") or ""))
+ for item in items
+ }
+ if len(periods) != 1 or len(scopes) != 1 or len(units) != 1:
+ return None
+ evidence_ids = tuple(str(item["evidence_id"]) for item in items)
+ primary_ids = tuple(
+ dict.fromkeys(
+ source_id
+ for item in items
+ for source_id in _claim_source_support(item)[0]
+ )
+ )
+ secondary_ids = tuple(
+ dict.fromkeys(
+ source_id
+ for item in items
+ for source_id in _claim_source_support(item)[1]
+ )
+ )
+ return ResearchClaim(
+ claim_id=_content_id(
+ "claim",
+ {"kind": "cash_conversion_decline" if bearish else "cash_conversion_stable", "evidence_ids": evidence_ids},
+ ),
+ claim="增长的现金实现质量下降" if bearish else "增长的现金实现质量保持稳定",
+ direction="bearish" if bearish else "bullish",
+ scope="growth_quality",
+ evidence_ids=evidence_ids,
+ claim_status=(
+ SupportStatus.STRONGLY_SUPPORTED
+ if len(set((*primary_ids, *secondary_ids))) >= 2 or primary_ids
+ else SupportStatus.SUPPORTED
+ ),
+ applicable_period=next(iter(periods)),
+ conditions=("经营现金流、应收账款与营业收入同比指标采用可比报告期和一致口径。",),
+ invalidators=(
+ "经营现金流同比转正,或应收账款同比不再高于营业收入同比。"
+ if bearish
+ else "经营现金流同比转负,且应收账款同比高于营业收入同比。",
+ ),
+ missing_evidence_effect=MissingEvidenceEffect.NO_MATERIAL_EFFECT,
+ primary_source_ids=primary_ids,
+ secondary_source_ids=secondary_ids,
+ calculation_input_evidence_ids=evidence_ids,
+ )
+
+
+def _unpublished_question(
+ lens_id: str,
+ scope: str,
+ question: str,
+ missing_evidence: tuple[str, ...],
+) -> dict[str, Any]:
+ return {
+ "question_id": _content_id(
+ "question",
+ {"lens_id": lens_id, "scope": scope, "question": question},
+ ),
+ "question": question,
+ "scope": scope,
+ "missing_evidence": list(missing_evidence),
+ "missing_evidence_effect": MissingEvidenceEffect.BLOCKS_CLAIM.value,
+ "publication_decision": PublicationDecision.AUDIT_ONLY.value,
+ "reason": "相关证据未达到命题发布门槛。",
+ }
+
+
+def _company_claims(evidence: dict[str, Any]) -> list[ResearchClaim]:
+ metric_items = _all_metric_items(evidence)
+ evidence_by_id = {
+ str(item["evidence_id"]): item
+ for _, item in metric_items
+ }
+ claims = [
+ _metric_claim(
+ module,
+ item,
+ str(evidence["trade_date"]),
+ evidence_by_id,
+ )
+ for module, item in metric_items
+ ]
+ cash_conversion = _cash_conversion_claim(metric_items, str(evidence["trade_date"]))
+ if cash_conversion is not None:
+ claims.append(cash_conversion)
+ known_evidence_ids = set(_module_evidence_ids(evidence, tuple(evidence["modules"])))
+ for claim in claims:
+ validate_claim_evidence_ids(claim, known_evidence_ids)
+ return claims
+
+
+def _claim_module(claim: ResearchClaim) -> str:
+ """Map derived scopes back to their research module for question relevance."""
+
+ return {"growth_quality": "C3"}.get(claim.scope, claim.scope)
+
+
def build_company_lens_opinions(
snapshot: dict[str, Any],
lenses: tuple[str, ...] | list[str] | None = None,
@@ -129,7 +323,14 @@ def build_company_lens_opinions(
selected = tuple(lenses or select_company_committee(research_question))
engine = LensEngine(mode="committee", lenses=selected)
evidence = snapshot["evidence"]
+ question_modules = set(
+ relevant_research_modules(research_question, asset_type="company")
+ )
metric_items = _all_metric_items(evidence)
+ claims = _company_claims(evidence)
+ market_share_available = any(
+ "market_share" in str(item["metric"]).lower() for _, item in metric_items
+ )
opinions: dict[str, dict[str, Any]] = {}
for lens_id in engine.lenses:
definition = engine.definitions[lens_id]
@@ -149,30 +350,65 @@ def build_company_lens_opinions(
}
for module, item in metric_items
]
+ # Plan section 8: a question is answerable only through claims relevant
+ # to modules selected for that question; unrelated claims carry no weight.
+ relevant_claims = [
+ claim
+ for claim in claims
+ if _claim_module(claim) in required
+ and _claim_module(claim) in question_modules
+ ]
+ claim_partition = partition_claims(relevant_claims)
+ unpublished_questions = list(claim_partition["unpublished_claims"])
+ unpublished_questions.extend(
+ _unpublished_question(
+ lens_id,
+ module,
+ f"{module} 所需研究问题尚未形成可发布命题。",
+ tuple(str(gap) for gap in (evidence["modules"].get(module) or {}).get("gaps") or (module,)),
+ )
+ for module in missing
+ )
+ if not market_share_available:
+ unpublished_questions.append(
+ _unpublished_question(
+ lens_id,
+ "C4",
+ "市场份额变化是否支持竞争地位判断?",
+ ("market_share",),
+ )
+ )
readiness = len(available) / len(required) if required else 0.0
- status = "review_ready" if not missing else "evidence_insufficient"
+ publishable_claims = claim_partition["publishable_claims"]
+ confidence = (
+ "high"
+ if any(claim["claim_status"] == SupportStatus.STRONGLY_SUPPORTED.value for claim in publishable_claims)
+ else "medium"
+ if publishable_claims
+ else "low"
+ )
body = {
"lens_id": lens_id,
"lens_name": definition.get("chinese_name") or definition.get("name") or lens_id,
"evidence_snapshot_id": snapshot["snapshot_id"],
- "status": status,
+ "status": "review_ready",
"required_modules": list(required),
"available_modules": list(available),
"missing_modules": list(missing),
"supporting_evidence_ids": supporting,
"counter_evidence_ids": counter,
"metric_analyses": metric_analyses,
+ "publishable_claims": publishable_claims,
+ "report_blockers": claim_partition.get("report_blockers", []),
+ "unpublished_questions": unpublished_questions,
"research_question": research_question or DEFAULT_RESEARCH_QUESTION,
+ "question_modules": sorted(question_modules),
"readiness": round(readiness, 3),
- "confidence": "high" if readiness >= 0.85 else "medium" if readiness >= 0.6 else "low",
+ "confidence": confidence,
"framework": definition.get("core_philosophy"),
"valuation_preference": definition.get("valuation_preference"),
"risk_focus": definition.get("risk_focus"),
- "conclusion": (
- "框架所需证据仍有缺口,维持观察。"
- if missing
- else "已具备框架复核基础;仍需人工确认估值假设与行动条件。"
- ),
+ "conclusion": f"已形成 {len(publishable_claims)} 条可发布命题。",
}
body["opinion_id"] = _content_id(f"opinion:{lens_id}", body)
opinions[lens_id] = body
@@ -189,41 +425,59 @@ def synthesize_company_committee(
raise ValueError("all company opinions must consume the same frozen Evidence snapshot")
ordered_ids = list(opinions)
- insufficient = [lens_id for lens_id in ordered_ids if opinions[lens_id]["status"] != "review_ready"]
- ready = [lens_id for lens_id in ordered_ids if lens_id not in insufficient]
- missing_modules = list(snapshot["evidence"]["_meta"].get("missing_modules") or [])
- core_modules = {"C2", "C3", "C5", "C6", "C7"}
- available_modules = set(snapshot["evidence"]["_meta"].get("available_modules") or [])
- action = "manual_review" if core_modules <= available_modules else "observe"
- consensus = [
- "全部 Company lens 使用同一冻结 Evidence 快照,未发生跨版本拼接。",
- (
- "核心质量、增长、资本配置、估值与风险模块尚未同时通过,只保留观察条件。"
- if action == "observe"
- else "核心结构化证据已可支持条件化研究判断;商业质量与护城河缺口仍作为人工复核条件。"
- ),
- ]
+ publishable_by_id: dict[str, dict[str, Any]] = {}
+ unpublished_by_id: dict[str, dict[str, Any]] = {}
+ report_blockers_by_id: dict[str, dict[str, Any]] = {}
+ for opinion in opinions.values():
+ for claim in opinion.get("publishable_claims") or []:
+ publishable_by_id.setdefault(str(claim["claim_id"]), claim)
+ for question in opinion.get("unpublished_questions") or []:
+ audit_id = str(question.get("claim_id") or question.get("question_id"))
+ unpublished_by_id.setdefault(audit_id, question)
+ for claim in opinion.get("report_blockers") or []:
+ report_blockers_by_id.setdefault(str(claim["claim_id"]), claim)
+ publishable_claims = list(publishable_by_id.values())
+ report_blockers = list(report_blockers_by_id.values())
+ safety_gate = evaluate_safety_gate(
+ snapshot["evidence"],
+ publishable_claims,
+ asset_type="company",
+ report_blockers=report_blockers,
+ )
+ publication_status = safety_gate["decision"]
+ consensus = [str(claim["claim"]) for claim in publishable_claims]
+ directions_by_scope: dict[str, set[str]] = {}
+ for claim in publishable_claims:
+ directions_by_scope.setdefault(str(claim["scope"]), set()).add(str(claim["direction"]))
disagreements = [
- f"证据门分化:ready={', '.join(ready) or '无'};insufficient={', '.join(insufficient) or '无'}。",
- "各 lens 对商业质量、资本配置、估值与宏观风险的优先级不同;差异保留在各自 required_modules 与 valuation_preference 中。",
+ f"{scope} 同时存在 bullish 与 bearish 可发布命题。"
+ for scope, directions in directions_by_scope.items()
+ if {"bullish", "bearish"} <= directions
]
- risk_vetoes = [f"缺失 Company module:{module}" for module in missing_modules]
- if not risk_vetoes:
- c7 = snapshot["evidence"]["modules"].get("C7") or {}
- risk_vetoes.extend(f"C7 缺口:{gap}" for gap in c7.get("gaps") or [])
+ if not disagreements:
+ disagreements.append("当前可发布命题未形成相反方向的证据冲突。")
+ action_conditions = list(
+ dict.fromkeys(
+ str(condition)
+ for claim in publishable_claims
+ for condition in claim.get("conditions") or []
+ )
+ )
body = {
"schema_version": "1.0",
"evidence_snapshot_id": snapshot["snapshot_id"],
"opinion_ids": [opinions[lens_id]["opinion_id"] for lens_id in ordered_ids],
"members": ordered_ids,
+ "publishable_claims": publishable_claims,
+ "report_blockers": report_blockers,
+ "unpublished_questions": list(unpublished_by_id.values()),
"consensus": consensus,
"disagreements": disagreements,
- "risk_vetoes": risk_vetoes,
- "action": action,
- "action_conditions": [
- "补齐 risk_vetoes 后重新冻结 Evidence 并重跑全部 Company lens。",
- "估值与资本配置判断必须引用结构化 C5/C6 evidence_id。",
- ],
+ "risk_vetoes": [],
+ "action": "block_report" if publication_status == PublicationDecision.BLOCK_REPORT.value else "manual_review",
+ "publication_status": publication_status,
+ "safety_gate": safety_gate,
+ "action_conditions": action_conditions,
"research_question": next(iter(opinions.values())).get("research_question") or DEFAULT_RESEARCH_QUESTION,
"evidence_consumption_audit": {
metric: [
diff --git a/src/stock_analysis/fund_research.py b/src/stock_analysis/fund_research.py
index f32536c..7993aa0 100644
--- a/src/stock_analysis/fund_research.py
+++ b/src/stock_analysis/fund_research.py
@@ -9,6 +9,7 @@
from pathlib import Path
from typing import Any
+from .committee_selection import relevant_research_modules
from .company_lens import interpret_metric_for_lens, select_company_committee
from .csi_index import FUND_INDEX_CODES, build_csi_index_snapshot
from .execution_costs import build_execution_cost_model
@@ -24,6 +25,14 @@
fetch_single_quote,
)
from .lens_engine import LensEngine
+from .research_claims import (
+ PublicationDecision,
+ build_claim_audit_artifacts,
+ build_evidence_integrity_audit,
+ compile_metric_claims,
+ evaluate_safety_gate,
+ partition_claims,
+)
from .research_workspace import DISCLAIMER
from .source_outcome import capture_source
from .time_series import compare_price_series
@@ -382,7 +391,7 @@ def build_fund_evidence(code: str, trade_date: str) -> dict[str, Any]:
}
available = [code for code, section in modules.items() if section["available"]]
missing = [code for code in FUND_MODULES if code not in available]
- return {
+ result = {
"schema_version": "1.0",
"asset_type": "fund",
"symbol": code,
@@ -432,6 +441,25 @@ def build_fund_evidence(code: str, trade_date: str) -> dict[str, Any]:
],
},
}
+ result["_meta"].update(
+ build_evidence_integrity_audit(
+ modules,
+ requested_symbol=code,
+ resolved_symbol=(
+ estimate.get("fundcode")
+ or profile.get("fundcode")
+ or price_volume.get("symbol")
+ or premium.get("fundcode")
+ or (
+ code
+ if any(section.get("evidence") for section in modules.values())
+ else None
+ )
+ ),
+ trade_date=trade_date,
+ )
+ )
+ return result
def freeze_fund_evidence(pack: dict[str, Any]) -> dict[str, Any]:
@@ -457,18 +485,34 @@ def synthesize_fund_committee(
evidence = snapshot["evidence"]
selected = tuple(lenses or select_company_committee(research_question, asset_type="fund"))
engine = LensEngine(mode="committee", lenses=selected)
+ question_modules = set(
+ relevant_research_modules(research_question, asset_type="fund")
+ )
metric_items = [
(module, item)
for module, section in evidence["modules"].items()
for item in section.get("evidence") or []
if item.get("metric") and item.get("evidence_id")
]
+ publishable_claims, unpublished_claims = compile_metric_claims(
+ evidence,
+ claim_prefix="fund-claim",
+ )
+ all_claims = [*publishable_claims, *unpublished_claims]
opinions = {}
for lens_id in engine.lenses:
definition = engine.definitions[lens_id]
name = definition.get("chinese_name") or definition.get("name") or lens_id
required = FUND_LENS_MODULES[lens_id]
available = [code for code in required if evidence["modules"][code]["available"]]
+ # Plan section 8: selected question modules define claim relevance.
+ claim_partition = partition_claims(
+ [
+ claim
+ for claim in all_claims
+ if claim.scope in required and claim.scope in question_modules
+ ]
+ )
body = {
"lens_id": lens_id,
"lens_name": name,
@@ -490,14 +534,36 @@ def synthesize_fund_committee(
for module, item in metric_items
],
"research_question": research_question or "指数、行业景气、估值、波动、组合风险与交易实现",
+ "question_modules": sorted(question_modules),
"framework": definition.get("core_philosophy"),
"risk_focus": definition.get("risk_focus"),
+ "publishable_claims": claim_partition["publishable_claims"],
+ "unpublished_questions": claim_partition["unpublished_claims"],
+ "report_blockers": claim_partition.get("report_blockers", []),
}
body["opinion_id"] = _content_id(f"fund-opinion:{lens_id}", body)
opinions[lens_id] = body
- core = {"F1", "F2", "F3", "F4", "F6", "F7"}
- available = set(evidence["_meta"]["available_modules"])
- action = "manual_review" if core <= available else "observe"
+ publishable_by_id: dict[str, dict[str, Any]] = {}
+ unpublished_by_id: dict[str, dict[str, Any]] = {}
+ report_blockers_by_id: dict[str, dict[str, Any]] = {}
+ for opinion in opinions.values():
+ for claim in opinion["publishable_claims"]:
+ publishable_by_id.setdefault(str(claim["claim_id"]), claim)
+ for claim in opinion["unpublished_questions"]:
+ unpublished_by_id.setdefault(str(claim["claim_id"]), claim)
+ for claim in opinion.get("report_blockers") or []:
+ report_blockers_by_id.setdefault(str(claim["claim_id"]), claim)
+ serialized_publishable = list(publishable_by_id.values())
+ serialized_unpublished = list(unpublished_by_id.values())
+ report_blockers = list(report_blockers_by_id.values())
+ safety_gate = evaluate_safety_gate(
+ evidence,
+ serialized_publishable,
+ asset_type="fund",
+ report_blockers=report_blockers,
+ )
+ publication_status = safety_gate["decision"]
+ action = "block_report" if publication_status == PublicationDecision.BLOCK_REPORT.value else "manual_review"
committee = {
"schema_version": "1.0",
"evidence_snapshot_id": snapshot["snapshot_id"],
@@ -505,15 +571,21 @@ def synthesize_fund_committee(
"members": list(opinions),
"consensus": [
"全部 Fund lens 使用同一冻结 Evidence 快照。",
- "主题 ETF 的收益潜力必须与集中度、波动、折溢价和跟踪质量共同评估。",
+ "委员会只综合达到发布门槛的命题,未解决问题不计入多空方向。",
],
- "disagreements": ["指数暴露价值与短期交易拥挤可能同时成立;底层估值缺口不由近期涨跌替代。"],
- "risk_vetoes": [gap for code in evidence["_meta"]["missing_modules"] for gap in evidence["modules"][code]["gaps"]],
+ "disagreements": [],
+ "risk_vetoes": [],
"action": action,
- "action_conditions": [
- "以组合风险预算而非单日涨跌决定是否进入人工配置复核。",
- "底层估值、持仓更新或折溢价显著变化后重新冻结 Evidence。",
- ],
+ "publication_status": publication_status,
+ "safety_gate": safety_gate,
+ "action_conditions": list(dict.fromkeys(
+ condition
+ for claim in serialized_publishable
+ for condition in claim["conditions"]
+ )),
+ "publishable_claims": serialized_publishable,
+ "report_blockers": report_blockers,
+ "unpublished_questions": serialized_unpublished,
"research_question": next(iter(opinions.values()))["research_question"],
"evidence_consumption_audit": {
metric: [
@@ -657,235 +729,115 @@ def _fund_report_v48(pack: dict[str, Any], opinions: dict[str, dict[str, Any]],
return "\n".join(lines)
-def _fund_report(pack: dict[str, Any], opinions: dict[str, dict[str, Any]], committee: dict[str, Any], changes: list[str]) -> str:
- """Render a fund committee memo while keeping pipeline diagnostics in the audit tail."""
+def _fund_claim_lines(claims: list[dict[str, Any]]) -> list[str]:
+ lines: list[str] = []
+ for claim in claims:
+ lines.extend([f"### {claim['claim']}", ""])
+ period = str(claim.get("applicable_period") or "").strip()
+ if period:
+ lines.append(f"- 适用期:{period}")
+ lines.extend(f"- 适用条件:{condition}" for condition in claim.get("conditions") or [])
+ lines.extend(f"- 失效条件:{invalidator}" for invalidator in claim.get("invalidators") or [])
+ lines.append("")
+ return lines
+
+
+def _fund_claims_by_section(claims: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
+ section_by_scope = {
+ "F1": 2,
+ "F2": 3,
+ "F3": 4,
+ "F4": 5,
+ "F5": 5,
+ "F6": 4,
+ "F7": 5,
+ "F8": 7,
+ }
+ grouped = {section: [] for section in range(2, 8)}
+ for claim in claims:
+ section = section_by_scope.get(str(claim.get("scope") or ""), 6)
+ grouped[section].append(claim)
+ return grouped
- profile = pack.get("profile") or {}
- returns = profile.get("returns") or {}
- scale = profile.get("scale") or {}
- managers = profile.get("managers") or []
- metrics = (pack.get("price_volume") or {}).get("metrics") or {}
- premium = pack.get("premium_discount") or {}
- latest = premium.get("latest") or {}
- metadata = premium.get("tracking_metadata") or {}
- estimate = pack.get("estimate") or {}
- holdings = (pack.get("holdings") or {}).get("holdings") or []
- index_snapshot = pack.get("index_snapshot") or {}
- index_constituents = index_snapshot.get("constituents") or []
- display_holdings = index_constituents[:10] if index_constituents else holdings
- holding_quotes = pack.get("holding_quotes") or {}
- top5 = _metric(pack, "F2", "top5_weight_pct")
- top10 = _metric(pack, "F2", "top10_weight_pct")
- valuation_coverage = _metric(pack, "F5", "disclosed_holdings_valuation_coverage_pct")
- harmonic_pe = _metric(pack, "F5", "positive_pe_harmonic_proxy")
- loss_weight = _metric(pack, "F5", "loss_making_disclosed_weight_pct")
- index_pe = _metric(pack, "F5", "index_pe_calculation_share")
- index_pe_total = _metric(pack, "F5", "index_pe_total_share")
- index_dividend_yield = _metric(pack, "F5", "index_dividend_yield_pct")
- index_valuation_asof = _metric(pack, "F5", "index_valuation_asof")
- index_history_sample = _metric(pack, "F6", "index_history_sample_size")
- index_return_60d = _metric(pack, "F6", "index_returns_60d")
- index_drawdown_60d = _metric(pack, "F6", "index_max_drawdown_60d_pct")
- index_volatility_60d = _metric(pack, "F6", "index_annualized_volatility_60d_pct")
- aligned_days = _metric(pack, "F4", "recomputed_aligned_days")
- tracking_error = _metric(pack, "F4", "recomputed_annualized_tracking_error_pct")
- correlation = _metric(pack, "F4", "recomputed_correlation")
- execution_status = _metric(pack, "F7", "execution_cost_model_status")
- execution_cost_100w = (
- _metric(pack, "F7", "execution_round_trip_cost_100w_bps")
- or _metric(pack, "F7", "execution_round_trip_cost_1m_bps")
- )
- index_count = int(_metric(pack, "F2", "index_constituent_count") or len(index_constituents))
- index_cap = _metric(pack, "F1", "index_single_constituent_cap_pct")
- minimum_index_nav = _metric(pack, "F1", "minimum_index_constituent_nav_pct")
- replication_method = _metric(pack, "F1", "replication_method")
- management_fee = _metric(pack, "F7", "management_fee_pct")
- custodian_fee = _metric(pack, "F7", "custodian_fee_pct")
- index_name = metadata.get("tracked_index") or metadata.get("benchmark") or "半导体主题指数"
- action_label = "审慎配置,先约束主题仓风险" if committee["action"] == "manual_review" else "审慎观察"
- research_question = committee.get("research_question") or "指数、行业景气、估值、波动、组合风险与交易实现"
- committee_names = "、".join(opinion["lens_name"] for opinion in opinions.values())
+
+def _fund_report(
+ pack: dict[str, Any],
+ opinions: dict[str, dict[str, Any]],
+ committee: dict[str, Any],
+ changes: list[str],
+) -> str:
+ """Render only committee-published claims as required by plan sections 3-5."""
+
+ del changes
+ publication_status = str(committee.get("publication_status") or PublicationDecision.PUBLISH.value)
+ title = f"# {pack['name']}({pack['symbol']})基金深度研究报告 · 投委会"
+ if publication_status == PublicationDecision.BLOCK_REPORT.value:
+ reasons = [
+ str(issue["reason"])
+ for issue in (committee.get("safety_gate") or {}).get("issues") or []
+ if issue.get("decision") == PublicationDecision.BLOCK_REPORT.value
+ ]
+ return "\n".join(
+ [
+ title,
+ "",
+ f"**报告日期**:{pack['trade_date']}",
+ "",
+ "## 重大安全阻断",
+ "",
+ *(f"- {reason}" for reason in reasons),
+ "",
+ DISCLAIMER,
+ "股市有风险,投资需谨慎。",
+ "",
+ ]
+ )
+
+ claims = list(committee.get("publishable_claims") or [])
+ grouped = _fund_claims_by_section(claims)
+ committee_names = "、".join(str(opinion["lens_name"]) for opinion in opinions.values())
lines = [
- f"# {pack['name']}({pack['symbol']})基金深度研究报告 · 投委会",
+ title,
"",
f"**报告日期**:{pack['trade_date']} ",
- f"**研究问题**:{research_question} ",
- f"**分析模式**:动态投委会({committee_names}) ",
- "**研究原则**:先判断买到什么,再判断收益来自哪里、承担什么风险、如何低摩擦实现;所有观点采用同一研究时点的数据",
+ f"**研究问题**:{committee.get('research_question') or '基金研究'} ",
+ f"**分析模式**:动态投委会({committee_names})",
"",
"## 一、执行摘要",
"",
- "| 项目 | 投委会判断 |",
- "|---|---|",
- f"| 产品定位 | 跟踪 **{index_name}** 的高 beta 行业 ETF,不是宽基或低波动核心资产替代 |",
- f"| 收益状态 | 近 1 月 **{_fmt(returns.get('近1月'), 2, '%')}**、近 3 月 **{_fmt(returns.get('近3月'), 2, '%')}**、近 1 年 **{_fmt(returns.get('近1年'), 2, '%')}**,周期弹性与路径波动都很高 |",
- f"| 风险状态 | 5/20/60 日场内收益 **{_fmt(metrics.get('returns_5d'), 2, '%')} / {_fmt(metrics.get('returns_20d'), 2, '%')} / {_fmt(metrics.get('returns_60d'), 2, '%')}**,ATR14 **{_fmt(metrics.get('atr_14_pct'), 2, '%')}** |",
- f"| 集中度 | 官方指数共 **{index_count}** 只样本,前五大 **{_fmt(top5, 2, '%')}**、前十大 **{_fmt(top10, 2, '%')}**,收益由头部公司与产业 beta 共同驱动 |",
- f"| 交易实现 | 最新折溢价 **{_fmt(latest.get('premium_discount_pct'), 2, '%')}**,20 日均值/波动 **{_fmt(premium.get('premium_discount_20d_mean_pct'), 2, '%')} / {_fmt(premium.get('premium_discount_20d_std_pct'), 2, '%')}** |",
- f"| 综合结论 | **{action_label}**:长期产业暴露有价值,但短期回撤与波动要求先定义主题仓风险预算,再讨论入场节奏 |",
- "",
- "==关键判断== ",
- f"近 1 年 {_fmt(returns.get('近1年'), 2, '%')} 的高收益与近 5 日 {_fmt(metrics.get('returns_5d'), 2, '%')} 的快速回撤并不矛盾:"
- "它们共同说明 512480 是高弹性产业工具。投资结论不能从“半导体长期成长”直接跳到“任何价格都适合买入”。",
- "",
- "## 二、产品定位与指数契约",
- "",
- f"- **跟踪标的**:{index_name}。基金的主要任务是复制指数暴露,而不是由基金经理主动选股获取 alpha。",
- f"- **指数方法**:剔除过去一年日均成交额后 10% 的证券,样本覆盖待选样本累计市值前 90%,单一成分权重上限 {_fmt(index_cap, 2, '%')};每年 6 月与 12 月定期调整。",
- f"- **复制约束**:成分股及备选成分股占基金净值不低于 {_fmt(minimum_index_nav, 2, '%')};主要采用 {replication_method or '完全复制法'}。",
- f"- **规模与运营**:最新公开规模 {_fmt(scale.get('latest_size_yi'), 2, '亿元')}({scale.get('asof') or '最近披露期'}),环比 {scale.get('mom') or '需结合份额数据复核'}。较大规模通常有利于流动性,但也需关注申赎与跟踪摩擦。",
- f"- **管理团队**:{', '.join(row.get('name') or '' for row in managers) or '公开档案未列示'}。ETF 评价重点仍是跟踪、申赎、流动性和运营稳定性,不以主动基金的选股叙事评价。",
- f"- **显性费率**:管理费 {_fmt(management_fee, 2, '%')}/年、托管费 {_fmt(custodian_fee, 2, '%')}/年。场内买卖还需承担券商佣金和价差。",
- f"- **跟踪质量**:ETF 与官方指数严格对齐 {int(aligned_days or 0)} 个交易日,重算年化 tracking error {_fmt(tracking_error, 2, '%')}、相关系数 {_fmt(correlation, 3)};页面披露值 {_fmt(metadata.get('reported_annual_tracking_error_pct'), 2, '%')} 仅作交叉检查。",
- "",
- "## 三、持仓结构与产业暴露",
- "",
- f"中证指数样本日:{index_snapshot.get('constituent_asof') or '最近发布日'};权重基准日:{index_snapshot.get('weight_asof') or '最近月末'}。完整样本 {index_count} 只,前十大合计 {_fmt(top10, 2, '%')}。下表展示官方权重最高的 10 只,而不是基金季报的部分重仓股。",
- "",
- "| # | 代码 | 名称 | 权重 | 最新价 | 涨跌 | PE 快照 |",
- "|---:|---|---|---:|---:|---:|---:|",
]
- for index, row in enumerate(display_holdings, start=1):
- quote = holding_quotes.get(str(row.get("code") or "")) or {}
- lines.append(
- f"| {index} | {row.get('code') or ''} | {row.get('name') or ''} | {_fmt(row.get('weight_pct'), 2, '%')} | "
- f"{_fmt(quote.get('price'), 2) if quote.get('price') is not None else '—'} | "
- f"{_fmt(quote.get('change_pct'), 2, '%') if quote.get('change_pct') is not None else '—'} | "
- f"{_fmt(quote.get('pe'), 2, 'x') if quote.get('pe') is not None else '—'} |"
- )
- lines.extend([
- "",
- "==暴露判断== 官方完整样本表解决了只看基金季报重仓股造成的偏差;指数仍会同时承受半导体资本开支周期、成长估值久期和科创风格拥挤。权重采用最近官方月末文件,不把月末权重误称为盘中实时权重。",
- "",
- "## 四、业绩、趋势与风险",
- "",
- "| 维度 | 数值 | 解读 |",
- "|---|---:|---|",
- f"| 近1月 | {_fmt(returns.get('近1月'), 2, '%')} | 短周期动量已明显降温 |",
- f"| 近3月 | {_fmt(returns.get('近3月'), 2, '%')} | 中期产业 beta 仍有正贡献 |",
- f"| 近6月 | {_fmt(returns.get('近6月'), 2, '%')} | 高收益来自趋势与估值共同扩张 |",
- f"| 近1年 | {_fmt(returns.get('近1年'), 2, '%')} | 基数很高,不能线性外推 |",
- f"| 5日/20日 | {_fmt(metrics.get('returns_5d'), 2, '%')} / {_fmt(metrics.get('returns_20d'), 2, '%')} | 短期处于急跌与去拥挤阶段 |",
- f"| 60日 | {_fmt(metrics.get('returns_60d'), 2, '%')} | 中期趋势尚未被短期回撤完全抹去 |",
- f"| ATR14 | {_fmt(metrics.get('atr_14_pct'), 2, '%')} | 单日正常波动区间已很高,仓位大小比方向判断更重要 |",
- f"| 60日最大回撤 | {_fmt(metrics.get('max_drawdown_60d_pct'), 2, '%')} | 直接衡量近期持有路径中的峰谷损失 |",
- f"| 60日年化波动 | {_fmt(metrics.get('annualized_volatility_60d_pct'), 2, '%')} | 用于把主题仓转换为组合风险预算 |",
- f"| 标的指数日线 | {int(index_history_sample or 0)} 个样本 | 官方指数日线,不用 ETF 价格替代标的指数 |",
- f"| 指数60日收益/回撤 | {_fmt(index_return_60d, 2, '%')} / {_fmt(index_drawdown_60d, 2, '%')} | 区分指数 beta 与基金交易实现 |",
- f"| 指数60日年化波动 | {_fmt(index_volatility_60d, 2, '%')} | 与 ETF 波动交叉验证 |",
- f"| 成交量 z-score | {_fmt(metrics.get('volume_zscore'), 2)} | 放量波动,表明分歧与换手同步上升 |",
- "",
- "## 五、估值、折溢价与交易实现",
- "",
- f"- **净值与盘中估算**:最近官方净值 {estimate.get('nav') or '—'}({estimate.get('nav_date') or '最近披露日'}),盘中估算 {estimate.get('estimate_nav') or '—'},估算变动 {_fmt(estimate.get('estimate_change_pct'), 2, '%')}。盘中估值只用于观察,不替代最终净值。",
- f"- **折溢价**:最新 {_fmt(latest.get('premium_discount_pct'), 2, '%')};20 日均值 {_fmt(premium.get('premium_discount_20d_mean_pct'), 2, '%')}、标准差 {_fmt(premium.get('premium_discount_20d_std_pct'), 2, '%')}。当前交易摩擦主要来自高波动,而非异常溢价。",
- f"- **完整指数估值**:中证指数 {index_valuation_asof or '最近交易日'} 官方计算用股本口径 PE {_fmt(index_pe, 2, 'x')}、总股本口径 PE {_fmt(index_pe_total, 2, 'x')}、股息率 {_fmt(index_dividend_yield, 2, '%')},覆盖完整指数而非部分重仓股。基金季报重仓调和 PE {_fmt(harmonic_pe, 2, 'x')} 仅保留为交叉检查,覆盖权重 {_fmt(valuation_coverage, 2, '%')}、亏损权重 {_fmt(loss_weight, 2, '%')}。",
- f"- **交易成本情景**:模型状态 {execution_status or '输入不足'};100 万元订单估算往返成本 {_fmt(execution_cost_100w, 2, 'bps')}。该值包含买卖价差、佣金假设和基于 20 日成交额/波动率的市场冲击,不冒充用户真实成交成本。",
- "",
- "| 情景 | 产业与盈利条件 | 价格/交易条件 | 投委会含义 |",
- "|---|---|---|---|",
- "| Bull | 资本开支与国产替代兑现,头部盈利持续上修 | 折溢价稳定、趋势重新确认 | 产业 beta 可继续释放,但仍按主题仓管理 |",
- "| Base | 景气上行与高估值互相抵消 | 高波动震荡、折溢价围绕均值 | 收益更多来自再平衡与持有纪律 |",
- "| Bear | 盈利预期下修、估值久期压缩 | 放量下跌、流动性与折溢价恶化 | ATR 放大回撤,应主动收缩风险预算 |",
- "",
- "## 六、投委会审议",
- "",
- "| 委员框架 | 基于当前数据的判断 | 主要保留意见 |",
- "|---|---|---|",
- ])
- views = {
- "buffett": "把 ETF 视作一篮子企业,要求底层盈利与估值共同提供长期回报。",
- "munger": "先排除高估值、同质化暴露和追逐热门主题造成的组合错误。",
- "duan_yongping": "只有理解主要成分股如何创造现金,行业长期故事才可转化为持有逻辑。",
- "zhang_kun": "重视头部企业长期现金流、竞争格局以及主题仓的组合机会成本。",
- "graham": "当前完整指数 PE 很高,价格保护弱于盈利增长保护。",
- "klarman": "高波动与高估值并存,绝对回报依赖更好的买入赔率与明确催化。",
- "lynch": "半导体增长故事清晰,但必须由头部成分股盈利兑现验证。",
- "o_neil": "中期趋势仍强,短期量价急跌要求等待盈利与价格重新共振。",
- "wood": "国产替代与技术迭代提供长期空间,但高估值放大技术路线和融资风险。",
- "dalio": "该 ETF 是高 beta、强周期风险资产,仓位应服务于组合风险平衡。",
- "soros": "产业预期、资金流和价格会互相强化,也可能在拥挤逆转时快速反身。",
- "livermore": "短期趋势已受损,先等关键点和量价确认,再讨论方向。",
- "minervini": "高增长预期尚未与低波动入场形态匹配,风险收益比优先。",
- "simons": "收益窗口、ATR、成交量和折溢价可复核,但需要成本后统计优势。",
- "feng_liu": "急跌改善赔率,但只有盈利或产业数据出现边际变化才构成认知差。",
- }
- reservations = {
- "buffett": "完整指数估值已有,但仍需拆解成分股盈利质量与现金创造。",
- "munger": "需核对与现有科技持仓的重复暴露。",
- "duan_yongping": "ETF 结构降低了逐家公司理解深度。",
- "zhang_kun": "主题仓不能替代长期核心资产配置。",
- "graham": "高 PE 成分的下行保护较弱。",
- "klarman": "催化不兑现时回撤可能快于基本面变化。",
- "lynch": "高增长基数不能线性外推。",
- "o_neil": "短期趋势与成交尚未重新确认。",
- "wood": "技术路线与估值久期风险较高。",
- "dalio": "需结合全组合相关性和风险预算。",
- "soros": "拥挤交易反转会放大净值波动。",
- "livermore": "不在下降趋势中机械摊平。",
- "minervini": "等待波动收缩和相对强度恢复。",
- "simons": (
- "指数日线与交易成本情景已覆盖;仍需用实际订单金额、券商佣金和成交回报校准。"
- if index_history_sample is not None and float(index_history_sample) >= 61
- and execution_status == "scenario_complete"
- else "标的指数日线或交易成本情景输入仍不完整。"
- ),
- "feng_liu": "急跌本身不是反转催化。",
+ directional_claims = [claim for claim in claims if claim.get("direction") != "neutral"]
+ lines.extend(_fund_claim_lines(directional_claims or claims[:3]))
+ sections = {
+ 2: "## 二、产品定位与指数契约",
+ 3: "## 三、持仓结构与产业暴露",
+ 4: "## 四、业绩、趋势与风险",
+ 5: "## 五、估值、折溢价与交易实现",
+ 6: "## 六、投委会审议",
+ 7: "## 七、风险、催化剂与跟踪指标",
}
- for lens_id, opinion in opinions.items():
- consumed = {item["metric"]: item.get("value") for item in opinion.get("metric_analyses") or []}
- data_summary = (
- f"完整指数 PE {_fmt(consumed.get('index_pe_calculation_share') or consumed.get('positive_pe_harmonic_proxy'), 2, 'x')}、"
- f"5 日收益 {_fmt(consumed.get('returns_5d'), 2, '%')}、"
- f"ATR {_fmt(consumed.get('atr_14_pct'), 2, '%')}、"
- f"60 日最大回撤 {_fmt(consumed.get('max_drawdown_60d_pct'), 2, '%')}、"
- f"指数样本 {int(consumed.get('index_history_sample_size') or 0)}、"
- f"重算跟踪误差 {_fmt(consumed.get('recomputed_annualized_tracking_error_pct'), 2, '%')}、"
- f"100万元往返成本 {_fmt(consumed.get('execution_round_trip_cost_100w_bps') or consumed.get('execution_round_trip_cost_1m_bps'), 2, 'bps')};"
+ for section, heading in sections.items():
+ lines.extend([heading, ""])
+ lines.extend(_fund_claim_lines(grouped[section]))
+ lines.extend(["## 八、投委会结论与条件化动作", ""])
+ if publication_status == PublicationDecision.BLOCK_ACTION.value:
+ action_reasons = [
+ str(issue["reason"])
+ for issue in (committee.get("safety_gate") or {}).get("issues") or []
+ if issue.get("decision") == PublicationDecision.BLOCK_ACTION.value
+ ]
+ lines.extend(
+ [
+ "估值或交易执行行动被阻断;本报告不生成具体仓位、价格区间或买卖指令。",
+ *(f"- {reason}" for reason in action_reasons),
+ "",
+ ]
)
- if lens_id == "simons":
- data_summary = data_summary.replace("指数样本", "指数日线样本")
- lines.append(f"| {opinion['lens_name']} | {data_summary}{views[lens_id]} | {reservations[lens_id]} |")
- lines.extend([
- "",
- "**投委会共识**:长期产业逻辑、短期拥挤、底层估值和交易实现必须同时评估。 ",
- "**核心分歧**:产业成长派更重视国产替代与盈利弹性,价值与风险派更重视高估值、近期回撤和组合中已有科技暴露。",
- "",
- "## 七、风险、催化剂与跟踪指标",
- "",
- "| 类型 | 可证伪指标 |",
- "|---|---|",
- "| 产业催化 | 晶圆厂/设备资本开支、国产替代订单、存储价格与下游需求上修 |",
- "| 盈利验证 | 头部成分股收入、利润、研发投入和订单兑现是否跟上估值 |",
- "| 组合风险 | 官方指数集中度、科创/成长风格相关性、现有组合重复暴露 |",
- "| 交易风险 | 折溢价偏离 20 日区间、成交量异常、跟踪偏离与流动性下降 |",
- "| 重跑触发 | 新定期报告、指数调样、折溢价显著偏离或 20 日风险指标跳变 |",
- "",
- "## 八、投委会结论与条件化动作",
- "",
- f"**结论:{action_label}。** 512480 更适合作为风险预算内的半导体卫星仓工具,而不是依据过去一年涨幅升级为核心仓。",
- "",
- "| 情形 | 条件化动作 |",
- "|---|---|",
- "| 已有主题仓 | 核对与其他科技/科创持仓的重复暴露;用组合总风险而非单基金涨跌决定去留 |",
- "| 准备新增仓位 | 先定义可承受回撤和仓位上限;优先等待短期波动收敛、盈利验证或趋势重新确认 |",
- "| 溢价异常 | 若折溢价显著高于 20 日区间,避免用市价单追高,等待交易摩擦回归 |",
- "| 产业证伪 | 若资本开支、订单与头部盈利同步下修,降低主题风险预算而非仅等待价格反弹 |",
- "",
- "### 后续跟踪重点",
- "",
- "- 指数定期调样后,集中度和头部成分是否发生实质变化。",
- "- 完整指数 PE、股息率及样本数量是否改善。",
- "- 净值回撤、跟踪偏离和折溢价是否超出近期正常区间。",
- "- 半导体资本开支、订单和主要成分股盈利是否继续上修。",
- "",
- DISCLAIMER,
- "股市有风险,投资需谨慎。",
- "",
- ])
+ else:
+ lines.extend(["以上可发布命题构成本轮条件化研究结论,不生成无条件买卖指令。", ""])
+ lines.extend([DISCLAIMER, "股市有风险,投资需谨慎。", ""])
return "\n".join(lines)
-
def _simple_doc(title: str, pack: dict[str, Any], body: list[str]) -> str:
return "\n".join([f"# {title}:{pack['name']}({pack['symbol']})", "", *body, ""])
@@ -905,6 +857,7 @@ def build_fund_research_workspace(
changes = ["首次研究,无历史基线。"] if not baseline else ["已建立上一期基线;本期按冻结 Evidence 重新复核。"]
snapshot = freeze_fund_evidence(pack)
committee, opinions = synthesize_fund_committee(snapshot, research_question=research_question, lenses=lenses)
+ audit_artifacts = build_claim_audit_artifacts(snapshot, opinions, committee)
now = datetime.now(timezone.utc).isoformat()
evidence_json = json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n"
opinions_json = json.dumps(opinions, ensure_ascii=False, indent=2) + "\n"
@@ -919,6 +872,22 @@ def build_fund_research_workspace(
"committee_review": ("05-committee-review.md", _simple_doc("Fund Committee Review", pack, [f"- action:{committee['action']}", *[f"- {item}" for item in committee["consensus"]]])),
"decision_memo": ("06-decision-memo.md", _simple_doc("Fund Decision Memo", pack, [f"- action:{committee['action']}", *[f"- {item}" for item in committee["action_conditions"]], "", DISCLAIMER])),
"institutional_report": ("07-institutional-report.md", _fund_report(pack, opinions, committee, changes)),
+ "evidence_manifest": (
+ "evidence_manifest.json",
+ json.dumps(audit_artifacts["evidence_manifest"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "claim_ledger": (
+ "claim_ledger.json",
+ json.dumps(audit_artifacts["claim_ledger"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "coverage_report": (
+ "coverage_report.json",
+ json.dumps(audit_artifacts["coverage_report"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "unpublished_claims": (
+ "unpublished_claims.json",
+ json.dumps(audit_artifacts["unpublished_claims"], ensure_ascii=False, indent=2) + "\n",
+ ),
}
previous_artifacts = previous_manifest.get("artifacts") or {}
artifacts = {
@@ -935,7 +904,11 @@ def build_fund_research_workspace(
"committee_members": list(opinions),
"created_at": previous_manifest.get("created_at") or now,
"updated_at": now,
- "status": "ready_for_analysis" if committee["action"] == "manual_review" else "evidence_insufficient",
+ "status": {
+ "block_report": "blocked_report",
+ "block_action": "action_blocked",
+ }.get(committee["publication_status"], "ready_for_analysis"),
+ "publication_status": committee["publication_status"],
"stages": {stage: "complete" for stage in ("scope", "research_plan", "evidence_collection", "evidence_validation", "expert_analysis", "committee_review", "report")},
"baseline": {"trade_date": baseline.get("trade_date"), "path": baseline.get("workspace_path")} if baseline else None,
"evidence_snapshot": {
diff --git a/src/stock_analysis/futu_public.py b/src/stock_analysis/futu_public.py
index 0fe41c3..70d4063 100644
--- a/src/stock_analysis/futu_public.py
+++ b/src/stock_analysis/futu_public.py
@@ -8,7 +8,7 @@
import requests
FUTU_BASE_URL = "https://ai-news-search.futunn.com"
-USER_AGENT = "stock-analysis/4.15.0 (Skill)"
+USER_AGENT = "stock-analysis/4.16.0 (Skill)"
POSITIVE_CUES = (
"增长",
diff --git a/src/stock_analysis/research_claims.py b/src/stock_analysis/research_claims.py
new file mode 100644
index 0000000..2a49b91
--- /dev/null
+++ b/src/stock_analysis/research_claims.py
@@ -0,0 +1,843 @@
+"""Claim publication layer from plan sections 1, 2, 4, 7, and 8."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+
+# Plan sections 1 and 7: source sufficiency is discrete and provenance-derived.
+PRIMARY_SOURCE_TYPES = frozenset(
+ {
+ "issuer_primary_disclosure",
+ "primary_disclosure",
+ "regulator_primary_xbrl",
+ "market_quote",
+ "market_valuation_snapshot",
+ }
+)
+SECONDARY_SOURCE_TYPES = frozenset(
+ {
+ "structured_public_disclosure",
+ "secondary_aggregated_financial",
+ "public_announcement_index",
+ "news_sample",
+ "observable_moat_proxy",
+ }
+)
+SOURCE_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
+
+
+class SupportStatus(str, Enum):
+ """Discrete evidence-support states for one research claim."""
+
+ STRONGLY_SUPPORTED = "strongly_supported"
+ SUPPORTED = "supported"
+ UNSUPPORTED = "unsupported"
+ SPECULATIVE = "speculative"
+ CONFLICTED_UNRESOLVED = "conflicted_unresolved"
+
+
+class MissingEvidenceEffect(str, Enum):
+ """How missing evidence affects publication of one claim."""
+
+ NO_MATERIAL_EFFECT = "no_material_effect"
+ NARROWS_SCOPE = "narrows_scope"
+ BLOCKS_CLAIM = "blocks_claim"
+ BLOCKS_ACTION = "blocks_action"
+
+
+class PublicationDecision(str, Enum):
+ """Publication outcome for one research claim."""
+
+ PUBLISH = "publish"
+ PUBLISH_NARROWED = "publish_narrowed"
+ AUDIT_ONLY = "audit_only"
+ BLOCK_ACTION = "block_action"
+ BLOCK_REPORT = "block_report"
+
+
+def _tuple_of_strings(values: Iterable[str], field_name: str) -> tuple[str, ...]:
+ normalized = tuple(values)
+ if any(not isinstance(value, str) or not value.strip() for value in normalized):
+ raise ValueError(f"{field_name} must contain non-empty strings")
+ return normalized
+
+
+def _source_identifier(source_type: str, fingerprint_material: str) -> str:
+ fingerprint = hashlib.sha256(fingerprint_material.encode("utf-8")).hexdigest()
+ return f"{source_type}@sha256:{fingerprint}"
+
+
+def _parse_source_identifier(identifier: str) -> tuple[str, str]:
+ source_type, separator, fingerprint = identifier.partition("@sha256:")
+ if (
+ not separator
+ or source_type not in PRIMARY_SOURCE_TYPES | SECONDARY_SOURCE_TYPES
+ or not SOURCE_FINGERPRINT_RE.fullmatch(fingerprint)
+ ):
+ raise ValueError(f"invalid strict source identifier: {identifier}")
+ return source_type, fingerprint
+
+
+def _source_identifiers_by_tier(
+ identifiers: Iterable[str], *, primary: bool
+) -> frozenset[str]:
+ allowed = PRIMARY_SOURCE_TYPES if primary else SECONDARY_SOURCE_TYPES
+ result = set()
+ for identifier in identifiers:
+ source_type, _ = _parse_source_identifier(identifier)
+ if source_type in allowed:
+ result.add(identifier)
+ return frozenset(result)
+
+
+def claim_source_ids(item: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ """Return deduplicated strict primary and secondary IDs for one evidence item."""
+
+ source_type = str(item.get("source_type") or "").strip().lower()
+ if source_type not in PRIMARY_SOURCE_TYPES | SECONDARY_SOURCE_TYPES:
+ return (), ()
+ fingerprint_material = str(item.get("url") or item.get("source") or "").strip()
+ if not fingerprint_material:
+ return (), ()
+ identifier = _source_identifier(source_type, fingerprint_material)
+ if source_type in PRIMARY_SOURCE_TYPES:
+ return (identifier,), ()
+ return (), (identifier,)
+
+
+@dataclass(frozen=True)
+class ResearchClaim:
+ """One auditable claim and the evidence rules governing its publication."""
+
+ claim_id: str
+ claim: str
+ direction: str
+ scope: str
+ evidence_ids: tuple[str, ...]
+ claim_status: SupportStatus
+ applicable_period: str = ""
+ conditions: tuple[str, ...] = field(default_factory=tuple)
+ invalidators: tuple[str, ...] = field(default_factory=tuple)
+ missing_evidence: tuple[str, ...] = field(default_factory=tuple)
+ missing_evidence_effect: MissingEvidenceEffect = MissingEvidenceEffect.NO_MATERIAL_EFFECT
+ primary_source_ids: tuple[str, ...] = field(default_factory=tuple)
+ secondary_source_ids: tuple[str, ...] = field(default_factory=tuple)
+ calculation_input_evidence_ids: tuple[str, ...] = field(default_factory=tuple)
+ report_blocking_reasons: tuple[str, ...] = field(default_factory=tuple)
+
+ def __post_init__(self) -> None:
+ for field_name in ("claim_id", "claim", "direction", "scope"):
+ value = getattr(self, field_name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{field_name} must be a non-empty string")
+ if not isinstance(self.applicable_period, str):
+ raise ValueError("applicable_period must be a string")
+ if not isinstance(self.claim_status, SupportStatus):
+ object.__setattr__(self, "claim_status", SupportStatus(self.claim_status))
+ if not isinstance(self.missing_evidence_effect, MissingEvidenceEffect):
+ object.__setattr__(
+ self,
+ "missing_evidence_effect",
+ MissingEvidenceEffect(self.missing_evidence_effect),
+ )
+ for field_name in (
+ "evidence_ids",
+ "conditions",
+ "invalidators",
+ "missing_evidence",
+ "primary_source_ids",
+ "secondary_source_ids",
+ "calculation_input_evidence_ids",
+ "report_blocking_reasons",
+ ):
+ object.__setattr__(self, field_name, _tuple_of_strings(getattr(self, field_name), field_name))
+
+ @property
+ def support_status(self) -> SupportStatus:
+ """Compatibility alias for consumers that use the support-status wording."""
+
+ return self.claim_status
+
+ @property
+ def primary_source_count(self) -> int:
+ """Derived count of unique, strictly allowed primary sources."""
+
+ return len(
+ {
+ _parse_source_identifier(identifier)[1]
+ for identifier in _source_identifiers_by_tier(
+ self.primary_source_ids, primary=True
+ )
+ }
+ )
+
+ @property
+ def secondary_source_count(self) -> int:
+ """Derived count of unique, strictly allowed secondary sources."""
+
+ return len(
+ {
+ _parse_source_identifier(identifier)[1]
+ for identifier in _source_identifiers_by_tier(
+ self.secondary_source_ids, primary=False
+ )
+ }
+ )
+
+ @property
+ def has_reproducible_calculation(self) -> bool:
+ """A calculation is reproducible only when its input evidence is explicit."""
+
+ return bool(self.calculation_input_evidence_ids)
+
+ def to_dict(self, *, include_decision: bool = True) -> dict[str, Any]:
+ """Serialize the claim into a stable, JSON-compatible audit record."""
+
+ payload: dict[str, Any] = {
+ "claim_id": self.claim_id,
+ "claim": self.claim,
+ "direction": self.direction,
+ "scope": self.scope,
+ "applicable_period": self.applicable_period,
+ "evidence_ids": list(self.evidence_ids),
+ "claim_status": self.claim_status.value,
+ "conditions": list(self.conditions),
+ "invalidators": list(self.invalidators),
+ "missing_evidence": list(self.missing_evidence),
+ "missing_evidence_effect": self.missing_evidence_effect.value,
+ "primary_source_ids": list(self.primary_source_ids),
+ "secondary_source_ids": list(self.secondary_source_ids),
+ "calculation_input_evidence_ids": list(self.calculation_input_evidence_ids),
+ "primary_source_count": self.primary_source_count,
+ "secondary_source_count": self.secondary_source_count,
+ "has_reproducible_calculation": self.has_reproducible_calculation,
+ "report_blocking_reasons": list(self.report_blocking_reasons),
+ }
+ if include_decision:
+ payload["publication_decision"] = publication_decision(self).value
+ return payload
+
+
+def validate_claim_evidence_ids(
+ claim: ResearchClaim,
+ known_evidence_ids: Iterable[str] | None = None,
+) -> None:
+ """Reject missing, duplicate, or out-of-snapshot evidence references."""
+
+ evidence_ids = claim.evidence_ids
+ if len(evidence_ids) != len(set(evidence_ids)):
+ raise ValueError(f"{claim.claim_id} contains duplicate evidence_ids")
+ if claim.claim_status in {SupportStatus.STRONGLY_SUPPORTED, SupportStatus.SUPPORTED} and not evidence_ids:
+ raise ValueError(f"{claim.claim_id} requires evidence_ids for a supported claim")
+ if len(claim.primary_source_ids) != len(set(claim.primary_source_ids)):
+ raise ValueError(f"{claim.claim_id} contains duplicate primary_source_ids")
+ if len(claim.secondary_source_ids) != len(set(claim.secondary_source_ids)):
+ raise ValueError(f"{claim.claim_id} contains duplicate secondary_source_ids")
+ primary_fingerprints = {_parse_source_identifier(identifier)[1] for identifier in claim.primary_source_ids}
+ secondary_fingerprints = {
+ _parse_source_identifier(identifier)[1] for identifier in claim.secondary_source_ids
+ }
+ if primary_fingerprints & secondary_fingerprints:
+ raise ValueError(f"{claim.claim_id} source fingerprints cannot occupy both tiers")
+ for identifier in claim.primary_source_ids:
+ source_type, _ = _parse_source_identifier(identifier)
+ if source_type not in PRIMARY_SOURCE_TYPES:
+ raise ValueError(f"{claim.claim_id} has a non-primary primary_source_id")
+ for identifier in claim.secondary_source_ids:
+ source_type, _ = _parse_source_identifier(identifier)
+ if source_type not in SECONDARY_SOURCE_TYPES:
+ raise ValueError(f"{claim.claim_id} has a non-secondary secondary_source_id")
+ calculation_inputs = set(claim.calculation_input_evidence_ids)
+ if len(calculation_inputs) != len(claim.calculation_input_evidence_ids):
+ raise ValueError(f"{claim.claim_id} contains duplicate calculation_input_evidence_ids")
+ unknown_inputs = calculation_inputs - set(evidence_ids)
+ if unknown_inputs:
+ rendered = ", ".join(sorted(unknown_inputs))
+ raise ValueError(f"{claim.claim_id} calculation inputs are not evidence_ids: {rendered}")
+ if known_evidence_ids is not None:
+ known = set(known_evidence_ids)
+ unknown = [evidence_id for evidence_id in evidence_ids if evidence_id not in known]
+ if unknown:
+ raise ValueError(f"{claim.claim_id} references unknown evidence_ids: {', '.join(unknown)}")
+
+
+def _meets_support_rule(claim: ResearchClaim) -> bool:
+ primary_fingerprints = {
+ _parse_source_identifier(identifier)[1] for identifier in claim.primary_source_ids
+ }
+ secondary_fingerprints = {
+ _parse_source_identifier(identifier)[1] for identifier in claim.secondary_source_ids
+ }
+ if claim.claim_status is SupportStatus.STRONGLY_SUPPORTED:
+ independent_sources = len(primary_fingerprints | secondary_fingerprints)
+ return independent_sources >= 2 or (
+ bool(primary_fingerprints) and claim.has_reproducible_calculation
+ )
+ if claim.claim_status is SupportStatus.SUPPORTED:
+ return (
+ bool(primary_fingerprints)
+ or len(secondary_fingerprints) >= 2
+ or claim.has_reproducible_calculation
+ )
+ return False
+
+
+def publication_decision(claim: ResearchClaim) -> PublicationDecision:
+ """Apply the rule-based supported-claim publication policy."""
+
+ if claim.report_blocking_reasons:
+ return PublicationDecision.BLOCK_REPORT
+ try:
+ validate_claim_evidence_ids(claim)
+ except ValueError:
+ return PublicationDecision.AUDIT_ONLY
+ if not _meets_support_rule(claim):
+ return PublicationDecision.AUDIT_ONLY
+ if not claim.applicable_period.strip() or not claim.conditions or not claim.invalidators:
+ return PublicationDecision.AUDIT_ONLY
+ if claim.missing_evidence_effect is MissingEvidenceEffect.BLOCKS_CLAIM:
+ return PublicationDecision.AUDIT_ONLY
+ if claim.missing_evidence_effect is MissingEvidenceEffect.BLOCKS_ACTION:
+ return PublicationDecision.BLOCK_ACTION
+ if claim.missing_evidence_effect is MissingEvidenceEffect.NARROWS_SCOPE:
+ return PublicationDecision.PUBLISH_NARROWED
+ return PublicationDecision.PUBLISH
+
+
+def partition_claims(claims: Sequence[ResearchClaim]) -> dict[str, list[dict[str, Any]]]:
+ """Split claims into investor-facing and audit-only serialized records."""
+
+ # Plan section 4: report blockers are isolated from ordinary unpublished claims.
+ publishable: list[dict[str, Any]] = []
+ unpublished: list[dict[str, Any]] = []
+ report_blockers: list[dict[str, Any]] = []
+ for claim in claims:
+ serialized = claim.to_dict()
+ decision = publication_decision(claim)
+ if decision is PublicationDecision.BLOCK_REPORT:
+ report_blockers.append(serialized)
+ elif decision in {
+ PublicationDecision.PUBLISH,
+ PublicationDecision.PUBLISH_NARROWED,
+ PublicationDecision.BLOCK_ACTION,
+ }:
+ publishable.append(serialized)
+ else:
+ unpublished.append(serialized)
+ return {
+ "publishable_claims": publishable,
+ "unpublished_claims": unpublished,
+ "report_blockers": report_blockers,
+ }
+
+
+def _stable_claim_id(prefix: str, payload: Any) -> str:
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
+ return f"{prefix}:{hashlib.sha256(encoded).hexdigest()[:16]}"
+
+
+def _render_value(value: Any) -> str:
+ if isinstance(value, float):
+ return f"{value:.4f}".rstrip("0").rstrip(".")
+ if isinstance(value, (dict, list, tuple)):
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+ return str(value)
+
+
+def _group_support(
+ items: Sequence[dict[str, Any]],
+) -> tuple[SupportStatus, tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
+ validations = {
+ str(item.get("validation_status") or "").lower()
+ for item in items
+ }
+ if validations & {"conflicted_unresolved", "conflict", "ambiguous"}:
+ return SupportStatus.CONFLICTED_UNRESOLVED, (), (), ()
+ if validations & {"rejected", "invalid"}:
+ return SupportStatus.UNSUPPORTED, (), (), ()
+
+ accepted = [
+ item
+ for item in items
+ if str(item.get("validation_status") or "").lower() == "accepted"
+ ]
+ primary_ids: list[str] = []
+ secondary_ids: list[str] = []
+ calculation_inputs: list[str] = []
+ for item in accepted:
+ primary, secondary = claim_source_ids(item)
+ primary_ids.extend(primary)
+ secondary_ids.extend(secondary)
+ calculation_inputs.extend(
+ str(evidence_id)
+ for evidence_id in item.get("calculation_input_evidence_ids") or []
+ if str(evidence_id).strip()
+ )
+ primary = tuple(dict.fromkeys(primary_ids))
+ secondary = tuple(dict.fromkeys(secondary_ids))
+ inputs = tuple(dict.fromkeys(calculation_inputs))
+ primary_fingerprints = {_parse_source_identifier(identifier)[1] for identifier in primary}
+ secondary_fingerprints = {_parse_source_identifier(identifier)[1] for identifier in secondary}
+ if len(primary_fingerprints | secondary_fingerprints) >= 2 or (
+ primary_fingerprints and inputs
+ ):
+ return SupportStatus.STRONGLY_SUPPORTED, primary, secondary, inputs
+ if primary_fingerprints or len(secondary_fingerprints) >= 2 or inputs:
+ return SupportStatus.SUPPORTED, primary, secondary, inputs
+ if accepted:
+ return SupportStatus.UNSUPPORTED, primary, secondary, inputs
+ return SupportStatus.SPECULATIVE, primary, secondary, inputs
+
+
+def validated_calculation_inputs(
+ item: dict[str, Any],
+ evidence_by_id: dict[str, dict[str, Any]],
+) -> tuple[str, ...]:
+ """Return calculation inputs only when the frozen inputs are comparable."""
+
+ input_ids = tuple(
+ dict.fromkeys(
+ str(evidence_id)
+ for evidence_id in item.get("calculation_input_evidence_ids") or []
+ if str(evidence_id).strip()
+ )
+ )
+ if not input_ids:
+ return ()
+ inputs = [evidence_by_id.get(evidence_id) for evidence_id in input_ids]
+ if any(
+ row is None
+ or str(row.get("validation_status") or "").lower() != "accepted"
+ for row in inputs
+ ):
+ return ()
+ rows = [row for row in inputs if row is not None]
+
+ def compatible(field: str) -> bool:
+ values = {str(row.get(field)) for row in rows if row.get(field) not in (None, "")}
+ output = item.get(field)
+ if output not in (None, ""):
+ values.add(str(output))
+ return len(values) <= 1
+
+ units = {
+ (str(row.get("unit") or ""), str(row.get("currency") or ""))
+ for row in rows
+ }
+ output_unit = (str(item.get("unit") or ""), str(item.get("currency") or ""))
+ if output_unit != ("", ""):
+ units.add(output_unit)
+ if not compatible("period") or not compatible("scope") or len(units) > 1:
+ return ()
+ return input_ids
+
+
+def compile_metric_claims(
+ evidence: dict[str, Any],
+ *,
+ claim_prefix: str,
+) -> tuple[list[ResearchClaim], list[ResearchClaim]]:
+ """Compile frozen metric evidence into publishable facts and audit-only gaps."""
+
+ # Plan sections 1, 2, and 7: consistent facts merge before discrete publication.
+ publishable: list[ResearchClaim] = []
+ unpublished: list[ResearchClaim] = []
+ trade_date = str(evidence.get("trade_date") or "unknown")
+ modules = evidence.get("modules") or {}
+ known_evidence_ids = {
+ str(item["evidence_id"])
+ for section in modules.values()
+ for item in section.get("evidence") or []
+ if item.get("evidence_id")
+ }
+ evidence_by_id = {
+ str(item["evidence_id"]): item
+ for section in modules.values()
+ for item in section.get("evidence") or []
+ if item.get("evidence_id")
+ }
+ grouped: dict[tuple[str, str, str, str, str, str], list[dict[str, Any]]] = {}
+ for module, section in modules.items():
+ for item in section.get("evidence") or []:
+ metric = item.get("metric")
+ evidence_id = item.get("evidence_id")
+ value = item.get("value")
+ if not metric or not evidence_id or value is None:
+ continue
+ period = str(
+ item.get("period")
+ or item.get("asof")
+ or item.get("published_at")
+ or trade_date
+ )
+ scope = str(item.get("scope") or module)
+ unit = str(item.get("unit") or item.get("currency") or "")
+ canonical_value = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ default=str,
+ )
+ normalized_item = dict(item)
+ normalized_item["calculation_input_evidence_ids"] = list(
+ validated_calculation_inputs(item, evidence_by_id)
+ )
+ key = (str(module), str(metric), period, scope, unit, canonical_value)
+ grouped.setdefault(key, []).append(normalized_item)
+
+ for key, items in grouped.items():
+ module, metric, period, scope, _, _ = key
+ section = modules[module]
+ gaps = tuple(str(gap) for gap in section.get("gaps") or [] if str(gap).strip())
+ value = items[0]["value"]
+ status, primary_ids, secondary_ids, calculation_inputs = _group_support(items)
+ evidence_ids = tuple(
+ dict.fromkeys(
+ [
+ *(str(item["evidence_id"]) for item in items),
+ *calculation_inputs,
+ ]
+ )
+ )
+ effect = (
+ MissingEvidenceEffect.NARROWS_SCOPE
+ if gaps
+ else MissingEvidenceEffect.NO_MATERIAL_EFFECT
+ )
+ claim = ResearchClaim(
+ claim_id=_stable_claim_id(claim_prefix, list(key)),
+ claim=f"在{period}的已披露口径内,{metric}为{_render_value(value)}。",
+ direction="neutral",
+ scope=scope,
+ evidence_ids=evidence_ids,
+ claim_status=status,
+ applicable_period=period,
+ conditions=("证据对象、期间、单位与计算口径保持一致",),
+ invalidators=(f"后续同口径披露修订{metric}",),
+ missing_evidence=gaps,
+ missing_evidence_effect=effect,
+ primary_source_ids=primary_ids,
+ secondary_source_ids=secondary_ids,
+ calculation_input_evidence_ids=calculation_inputs,
+ )
+ try:
+ validate_claim_evidence_ids(claim, known_evidence_ids)
+ except ValueError:
+ unpublished.append(claim)
+ continue
+ decision = publication_decision(claim)
+ if decision in {
+ PublicationDecision.PUBLISH,
+ PublicationDecision.PUBLISH_NARROWED,
+ PublicationDecision.BLOCK_ACTION,
+ }:
+ publishable.append(claim)
+ else:
+ unpublished.append(claim)
+
+ for module, section in modules.items():
+ if section.get("evidence"):
+ continue
+ gaps = tuple(str(gap) for gap in section.get("gaps") or [] if str(gap).strip())
+ for gap in gaps:
+ unpublished.append(
+ ResearchClaim(
+ claim_id=_stable_claim_id(claim_prefix, [module, gap]),
+ claim=f"{module}相关判断",
+ direction="neutral",
+ scope=str(module),
+ evidence_ids=(),
+ claim_status=SupportStatus.UNSUPPORTED,
+ applicable_period=trade_date,
+ conditions=("取得与研究对象及截止日一致的可核验证据",),
+ invalidators=("相关证据完成验证并达到发布门槛",),
+ missing_evidence=(gap,),
+ missing_evidence_effect=MissingEvidenceEffect.BLOCKS_CLAIM,
+ )
+ )
+ return publishable, unpublished
+
+
+def _normalized_period(value: Any) -> str:
+ return "".join(character for character in str(value or "") if character.isdigit())[:8]
+
+
+def _is_positive_number(value: Any) -> bool:
+ try:
+ return float(value) > 0
+ except (TypeError, ValueError):
+ return False
+
+
+def build_evidence_integrity_audit(
+ modules: dict[str, dict[str, Any]],
+ *,
+ requested_symbol: str,
+ resolved_symbol: str | None,
+ trade_date: str,
+) -> dict[str, Any]:
+ """Build production safety metadata for the plan-section-8 exceptions."""
+
+ requested = requested_symbol.strip().upper()
+ resolved = str(resolved_symbol or "").strip().upper()
+ identity_status = (
+ "unverified"
+ if not resolved
+ else "matched"
+ if resolved == requested
+ else "conflict"
+ )
+ cutoff = _normalized_period(trade_date)
+ violations: list[dict[str, Any]] = []
+ basis_conflicts: list[dict[str, Any]] = []
+ primary_conflicts: list[dict[str, Any]] = []
+ grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = {}
+
+ for module, section in modules.items():
+ for item in section.get("evidence") or []:
+ evidence_id = str(item.get("evidence_id") or "")
+ published = _normalized_period(item.get("published_at") or item.get("notice_date"))
+ if cutoff and published and published > cutoff:
+ violations.append(
+ {"evidence_id": evidence_id, "field": "published_at", "value": published}
+ )
+ metric = str(item.get("metric") or "")
+ asof_value = _normalized_period(item.get("asof"))
+ if cutoff and metric.endswith("_asof"):
+ asof_value = _normalized_period(item.get("value")) or asof_value
+ if cutoff and asof_value and asof_value > cutoff:
+ violations.append(
+ {"evidence_id": evidence_id, "field": "effective_period", "value": asof_value}
+ )
+ if str(item.get("validation_status") or "").lower() != "accepted":
+ continue
+ key = (
+ str(module),
+ metric,
+ str(item.get("period") or item.get("asof") or ""),
+ str(item.get("scope") or module),
+ )
+ grouped.setdefault(key, []).append(item)
+
+ for key, items in grouped.items():
+ currencies = {str(item.get("currency")) for item in items if item.get("currency")}
+ units = {str(item.get("unit")) for item in items if item.get("unit")}
+ if len(currencies) > 1 or len(units) > 1:
+ basis_conflicts.append(
+ {
+ "module": key[0],
+ "metric": key[1],
+ "period": key[2],
+ "currencies": sorted(currencies),
+ "units": sorted(units),
+ "evidence_ids": [str(item.get("evidence_id") or "") for item in items],
+ }
+ )
+ primary_items = [item for item in items if claim_source_ids(item)[0]]
+ values = {
+ json.dumps(item.get("value"), ensure_ascii=False, sort_keys=True, default=str)
+ for item in primary_items
+ }
+ if len(primary_items) >= 2 and len(values) > 1:
+ primary_conflicts.append(
+ {
+ "module": key[0],
+ "metric": key[1],
+ "period": key[2],
+ "evidence_ids": [
+ str(item.get("evidence_id") or "") for item in primary_items
+ ],
+ }
+ )
+
+ return {
+ "identity_validation": {
+ "status": identity_status,
+ "requested_symbol": requested,
+ "resolved_symbol": resolved or None,
+ },
+ "basis_conflicts": basis_conflicts,
+ "primary_conflicts": primary_conflicts,
+ "publication_cutoff_audit": {
+ "trade_date": trade_date,
+ "violations": violations,
+ },
+ }
+
+
+def evaluate_safety_gate(
+ evidence: dict[str, Any],
+ publishable_claims: Sequence[dict[str, Any]],
+ *,
+ asset_type: str,
+ report_blockers: Sequence[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ """Evaluate only explicit research-integrity and action-feasibility failures."""
+
+ # Plan section 8: explicit report blockers outrank action-level limitations.
+ issues: list[dict[str, str]] = []
+ meta = evidence.get("_meta") or {}
+
+ def add(code: str, decision: PublicationDecision, reason: str) -> None:
+ issues.append({"code": code, "decision": decision.value, "reason": reason})
+
+ identity_status = (meta.get("identity_validation") or {}).get("status")
+ if not str(evidence.get("symbol") or "").strip() or identity_status in {
+ "conflict",
+ "unverified",
+ }:
+ add("IDENTITY_CONFLICT", PublicationDecision.BLOCK_REPORT, "研究对象身份无法确认。")
+ if meta.get("basis_conflicts"):
+ add("BASIS_CONFLICT", PublicationDecision.BLOCK_REPORT, "报告期、币种或单位存在未解决冲突。")
+ if meta.get("primary_conflicts"):
+ add("PRIMARY_CONFLICT", PublicationDecision.BLOCK_REPORT, "关键一手披露存在未解决冲突。")
+ if (meta.get("publication_cutoff_audit") or {}).get("violations"):
+ add("LOOKAHEAD_BIAS", PublicationDecision.BLOCK_REPORT, "发布命题引用了研究截止日后的证据。")
+ if report_blockers:
+ add("CLAIM_BLOCKS_REPORT", PublicationDecision.BLOCK_REPORT, "命题层存在重大安全阻断。")
+ if not publishable_claims:
+ add("NO_SUPPORTED_CLAIMS", PublicationDecision.BLOCK_REPORT, "当前问题没有可发布命题。")
+ if any(
+ claim.get("publication_decision") == PublicationDecision.BLOCK_ACTION.value
+ for claim in publishable_claims
+ ):
+ add("CLAIM_BLOCKS_ACTION", PublicationDecision.BLOCK_ACTION, "可发布研究结论不具备交易行动条件。")
+
+ metric_items = [
+ item
+ for section in (evidence.get("modules") or {}).values()
+ for item in section.get("evidence") or []
+ if item.get("metric")
+ ]
+ metrics = {
+ str(item.get("metric")): item.get("value")
+ for item in metric_items
+ }
+ trade_period = _normalized_period(evidence.get("trade_date"))
+ current_metrics = {
+ str(item.get("metric")): item.get("value")
+ for item in metric_items
+ if str(item.get("validation_status") or "").lower() == "accepted"
+ and _normalized_period(
+ item.get("period") or item.get("asof") or item.get("trade_date")
+ )
+ == trade_period
+ }
+ if asset_type == "company":
+ if not _is_positive_number(current_metrics.get("market_quote")):
+ add("PRICE_UNAVAILABLE", PublicationDecision.BLOCK_ACTION, "当前价格不可得,估值与行动输出被阻断。")
+ if not _is_positive_number(current_metrics.get("total_market_cap")):
+ add("MARKET_CAP_UNAVAILABLE", PublicationDecision.BLOCK_ACTION, "总市值不可得,反向估值与行动输出被阻断。")
+ else:
+ price_rows = (evidence.get("price_volume") or {}).get("rows") or []
+ latest_market = (evidence.get("premium_discount") or {}).get("latest") or {}
+ latest_price = next(
+ (
+ row.get("close")
+ for row in reversed(price_rows)
+ if _normalized_period(row.get("date") or row.get("trade_date")) == trade_period
+ and _is_positive_number(row.get("close"))
+ ),
+ None,
+ )
+ if latest_price is None and _normalized_period(
+ latest_market.get("date") or latest_market.get("trade_date") or latest_market.get("asof")
+ ) == trade_period:
+ latest_price = latest_market.get("close") or latest_market.get("price")
+ if not _is_positive_number(latest_price):
+ add("PRICE_UNAVAILABLE", PublicationDecision.BLOCK_ACTION, "场内价格不可得,估值与行动输出被阻断。")
+
+ execution = evidence.get("execution_cost_model") or {}
+ execution_status = execution.get("model_status") or metrics.get("execution_cost_model_status")
+ if execution_status != "scenario_complete":
+ add("EXECUTION_UNAVAILABLE", PublicationDecision.BLOCK_ACTION, "流动性或交易成本输入不完整,交易行动被阻断。")
+
+ decisions = {item["decision"] for item in issues}
+ if PublicationDecision.BLOCK_REPORT.value in decisions:
+ decision = PublicationDecision.BLOCK_REPORT
+ elif PublicationDecision.BLOCK_ACTION.value in decisions:
+ decision = PublicationDecision.BLOCK_ACTION
+ else:
+ decision = PublicationDecision.PUBLISH
+ return {"decision": decision.value, "issues": issues}
+
+
+def build_claim_audit_artifacts(
+ snapshot: dict[str, Any],
+ opinions: dict[str, dict[str, Any]],
+ committee: dict[str, Any],
+) -> dict[str, Any]:
+ """Build the four fixed audit artifacts required by plan section 4."""
+
+ evidence = snapshot["evidence"]
+ evidence_rows = []
+ for module, section in (evidence.get("modules") or {}).items():
+ for item in section.get("evidence") or []:
+ if not item.get("evidence_id"):
+ continue
+ evidence_rows.append(
+ {
+ "evidence_id": item["evidence_id"],
+ "module": module,
+ "metric": item.get("metric"),
+ "period": item.get("period") or item.get("asof") or evidence.get("trade_date"),
+ "source": item.get("source"),
+ "source_type": item.get("source_type"),
+ "validation_status": item.get("validation_status") or item.get("confidence"),
+ }
+ )
+ publishable = list(committee.get("publishable_claims") or [])
+ report_blockers = list(committee.get("report_blockers") or [])
+ unpublished_by_id: dict[str, dict[str, Any]] = {}
+ for opinion in opinions.values():
+ for item in opinion.get("unpublished_questions") or []:
+ item_id = str(item.get("claim_id") or item.get("question_id"))
+ unpublished_by_id.setdefault(item_id, item)
+ unpublished = list(unpublished_by_id.values())
+ safety_gate = committee.get("safety_gate") or {}
+ return {
+ "evidence_manifest": {
+ "schema_version": "1.0",
+ "evidence_snapshot_id": snapshot["snapshot_id"],
+ "symbol": evidence.get("symbol"),
+ "trade_date": evidence.get("trade_date"),
+ "evidence": evidence_rows,
+ },
+ "claim_ledger": {
+ "schema_version": "1.0",
+ "evidence_snapshot_id": snapshot["snapshot_id"],
+ "publishable_claims": publishable,
+ "report_blockers": report_blockers,
+ "unpublished_claims": unpublished,
+ "safety_gate": safety_gate,
+ },
+ "coverage_report": {
+ "schema_version": "1.0",
+ "coverage": (evidence.get("_meta") or {}).get("coverage"),
+ "available_modules": (evidence.get("_meta") or {}).get("available_modules") or [],
+ "missing_modules": (evidence.get("_meta") or {}).get("missing_modules") or [],
+ "source_events": (evidence.get("_meta") or {}).get("source_events") or [],
+ "identity_validation": (evidence.get("_meta") or {}).get("identity_validation"),
+ "basis_conflicts": (evidence.get("_meta") or {}).get("basis_conflicts") or [],
+ "primary_conflicts": (evidence.get("_meta") or {}).get("primary_conflicts") or [],
+ "publication_cutoff_audit": (
+ (evidence.get("_meta") or {}).get("publication_cutoff_audit") or {}
+ ),
+ "narrowed_claim_ids": [
+ claim["claim_id"]
+ for claim in publishable
+ if claim.get("missing_evidence_effect") == MissingEvidenceEffect.NARROWS_SCOPE.value
+ ],
+ },
+ "unpublished_claims": {
+ "schema_version": "1.0",
+ "evidence_snapshot_id": snapshot["snapshot_id"],
+ "claims": unpublished,
+ },
+ }
diff --git a/src/stock_analysis/research_workspace.py b/src/stock_analysis/research_workspace.py
index 7ca27b8..f4737d8 100644
--- a/src/stock_analysis/research_workspace.py
+++ b/src/stock_analysis/research_workspace.py
@@ -7,12 +7,14 @@
from pathlib import Path
from typing import Any
+from .committee_selection import DEFAULT_RESEARCH_QUESTION
from .company_evidence import COMPANY_MODULES
from .company_lens import (
build_company_lens_opinions,
freeze_company_evidence,
synthesize_company_committee,
)
+from .research_claims import build_claim_audit_artifacts
from .workspace_store import (
atomic_write as _atomic_write,
)
@@ -396,220 +398,121 @@ def _institutional_report_v48(
return "\n".join(lines)
+def _claim_lines(claims: list[dict[str, Any]]) -> list[str]:
+ lines: list[str] = []
+ for claim in claims:
+ lines.extend([f"### {claim['claim']}", ""])
+ period = str(claim.get("applicable_period") or "").strip()
+ if period:
+ lines.append(f"- 适用期:{period}")
+ lines.extend(f"- 适用条件:{condition}" for condition in claim.get("conditions") or [])
+ lines.extend(f"- 失效条件:{invalidator}" for invalidator in claim.get("invalidators") or [])
+ lines.append("")
+ return lines
+
+
+def _claims_by_section(claims: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
+ section_by_scope = {
+ "C1": 2,
+ "C2": 3,
+ "C3": 3,
+ "C4": 2,
+ "C5": 4,
+ "C6": 5,
+ "C7": 7,
+ "C8": 7,
+ "business_quality": 2,
+ "financial_quality": 3,
+ "growth_quality": 3,
+ "governance": 4,
+ "capital_allocation": 4,
+ "valuation": 5,
+ "risk": 7,
+ "catalyst": 7,
+ }
+ grouped = {section: [] for section in range(2, 8)}
+ for claim in claims:
+ section = section_by_scope.get(str(claim.get("scope") or ""), 6)
+ grouped[section].append(claim)
+ return grouped
+
+
def _institutional_report(
pack: dict[str, Any],
changes: list[str],
opinions: dict[str, dict[str, Any]],
committee: dict[str, Any],
) -> str:
- """Render the frozen snapshot as the classic Chinese committee memo."""
+ """Render only committee-published claims as required by plan sections 3-5."""
+
+ del changes
+ publication_status = str(committee.get("publication_status") or "publish")
+ title = f"# {pack['name']}({pack['symbol']})个股深度研究报告 · 投委会"
+ if publication_status == "block_report":
+ reasons = [
+ str(issue["reason"])
+ for issue in (committee.get("safety_gate") or {}).get("issues") or []
+ if issue.get("decision") == "block_report"
+ ]
+ return "\n".join(
+ [
+ title,
+ "",
+ f"**报告日期**:{pack['trade_date']}",
+ "",
+ "## 重大安全阻断",
+ "",
+ *(f"- {reason}" for reason in reasons),
+ "",
+ DISCLAIMER,
+ "股市有风险,投资需谨慎。",
+ "",
+ ]
+ )
- price = _metric(pack, "C6", "market_quote")
- pe = _metric(pack, "C6", "pe_static_proxy") or _metric(pack, "C6", "pe_ttm")
- pb = _metric(pack, "C6", "pb_reported_proxy") or _metric(pack, "C6", "pb")
- roe = _metric(pack, "C2", "roe_weighted")
- margin = _metric(pack, "C2", "gross_margin")
- debt = _metric(pack, "C2", "debt_asset_ratio")
- ocf = _metric(pack, "C2", "operating_cash_flow")
- fcf = _metric(pack, "C2", "free_cash_flow_lite")
- net_margin = _metric(pack, "C1", "parent_net_margin_pct")
- cash_conversion = _metric(pack, "C1", "operating_cash_conversion_pct")
- moat_margin = _metric(pack, "C4", "annual_gross_margin_pct")
- moat_range = _metric(pack, "C4", "annual_gross_margin_range_pct")
- revenue_yoy = _metric(pack, "C3", "revenue_yoy_pct")
- profit_yoy = _metric(pack, "C3", "parent_net_profit_yoy_pct")
- return_5d = _metric(pack, "C7", "returns_5d")
- return_20d = _metric(pack, "C7", "returns_20d")
- return_60d = _metric(pack, "C7", "returns_60d")
- atr = _metric(pack, "C7", "atr_14_pct")
- net_invest = _metric(pack, "C5", "net_cash_invest")
- net_finance = _metric(pack, "C5", "net_cash_finance")
- direct_sales = _metric(pack, "C1", "direct_sales_revenue")
- direct_sales_yoy = _metric(pack, "C1", "direct_sales_revenue_yoy_pct")
- wholesale_yoy = _metric(pack, "C1", "wholesale_revenue_yoy_pct")
- core_product_margin = _metric(pack, "C4", "core_product_gross_margin_pct")
- dividend_total = _metric(pack, "C5", "annual_dividend_total")
- payout_ratio = _metric(pack, "C5", "shareholder_payout_ratio_pct")
- execution_cost_100w = _metric(pack, "C7", "execution_round_trip_cost_100w_bps")
- series_revenue_yoy = _metric(pack, "C7", "series_revenue_yoy_pct")
- inventory_yoy = _metric(pack, "C7", "finished_and_base_inventory_yoy_pct")
- sales_expense_yoy = _metric(pack, "C7", "sales_expense_yoy_pct")
- reinvestment = (float(ocf) - float(fcf)) if ocf is not None and fcf is not None else None
- history = pack.get("financial_history") or []
- events = []
- for code in ("C7", "C8"):
- for item in pack["modules"][code]["evidence"]:
- title = item.get("title")
- if title and title not in events:
- events.append(str(title))
-
- action_label = "中性偏积极,等待更好赔率" if committee["action"] == "manual_review" else "审慎观察"
- research_question = committee.get("research_question") or "长期商业质量、估值与风险"
- committee_names = "、".join(opinion["lens_name"] for opinion in opinions.values())
+ claims = list(committee.get("publishable_claims") or [])
+ grouped = _claims_by_section(claims)
+ committee_names = "、".join(str(opinion["lens_name"]) for opinion in opinions.values())
lines = [
- f"# {pack['name']}({pack['symbol']})个股深度研究报告 · 投委会",
+ title,
"",
f"**报告日期**:{pack['trade_date']} ",
- f"**研究问题**:{research_question} ",
- f"**分析模式**:动态投委会({committee_names}) ",
- "**研究原则**:商业质量 → 财务兑现 → 资本配置 → 估值赔率 → 风险触发;所有观点采用同一研究时点的数据",
+ f"**研究问题**:{committee.get('research_question') or DEFAULT_RESEARCH_QUESTION} ",
+ f"**分析模式**:动态投委会({committee_names})",
"",
"## 一、执行摘要",
"",
- "| 项目 | 投委会判断 |",
- "|---|---|",
- f"| 商业经济性 | 毛利率 **{_fmt(margin, 2, '%')}**、归母净利率代理 **{_fmt(net_margin, 2, '%')}**,仍体现强定价与高盈利特征 |",
- f"| 财务质量 | ROE **{_fmt(roe, 2, '%')}**、资产负债率 **{_fmt(debt, 2, '%')}**、FCF-lite **{_fmt(float(fcf) / 1e8 if fcf is not None else None, 1, '亿元')}** |",
- f"| 增长状态 | 最新同口径营收 **{_fmt(revenue_yoy, 2, '%')}**、归母净利 **{_fmt(profit_yoy, 2, '%')}**;利润增速慢于收入,增长已从高增转向质量检验 |",
- f"| 价格与估值 | 现价 **{_fmt(price, 2, '元')}**,静态 PE 代理 **{_fmt(pe, 2, 'x')}**、PB 代理 **{_fmt(pb, 2, 'x')}** |",
- f"| 市场状态 | 5/20/60 日收益 **{_fmt(return_5d, 2, '%')} / {_fmt(return_20d, 2, '%')} / {_fmt(return_60d, 2, '%')}**,ATR14 **{_fmt(atr, 2, '%')}** |",
- f"| 综合结论 | **{action_label}**:生意与现金流质量仍强,当前价格处于合理估值区间,但增长降速与护城河运营指标需要继续验证 |",
- "",
- "==关键判断== ",
- f"投委会不把 {_fmt(return_60d, 2, '%')} 的 60 日价格变化直接解释为生意恶化;更关键的矛盾是:"
- f"在收入增速 {_fmt(revenue_yoy, 2, '%')}、利润增速 {_fmt(profit_yoy, 2, '%')} 的阶段,约 {_fmt(pe, 2, 'x')} 静态估值是否已提供足够赔率。",
- "",
- "## 二、行情、商业质量与核心矛盾",
- "",
- f"- **行情位置**:现价 {_fmt(price, 2, '元')};短中期价格并非单边趋势,20 日回升与 60 日回撤同时存在。",
- f"- **盈利结构**:最新报告期毛利率 {_fmt(margin, 2, '%')}、归母净利率代理 {_fmt(net_margin, 2, '%')}。高毛利是定价权的可观测代理,但不等同于完整护城河证明。",
- f"- **现金兑现**:经营现金流/归母净利代理 {_fmt(cash_conversion, 2, '%')};FCF-lite 为 {_fmt(float(fcf) / 1e8 if fcf is not None else None, 1, '亿元')}。利润与现金流方向一致,未出现明显“有利润、无现金”信号。",
- f"- **护城河代理**:最近可比年度毛利率 {_fmt(moat_margin, 2, '%')},样本区间波动约 {_fmt(moat_range, 2, 'pct')};这支持稳定盈利结构,但渠道批价、库存与市场份额仍是下一轮经营验证项。",
- f"- **渠道结构**:2025 年直销收入 {_fmt(float(direct_sales) / 1e8 if direct_sales is not None else None, 1, '亿元')}、同比 {_fmt(direct_sales_yoy, 2, '%')},批发代理同比 {_fmt(wholesale_yoy, 2, '%')}。直销增长部分对冲了传统批发渠道压力。",
- f"- **核心产品**:茅台酒毛利率 {_fmt(core_product_margin, 2, '%')};核心产品盈利能力明显高于公司整体,是质量判断的主要支点。",
- "",
- "## 三、财务质量、增长与现金流",
- "",
]
- if history:
- lines.extend([
- "| 期间 | ROE | 毛利率 | 负债率 | 营收 | 归母净利 | OCF | FCF-lite |",
- "|---|---:|---:|---:|---:|---:|---:|---:|",
- ])
- for row in history[:6]:
- lines.append(
- f"| {row.get('period_label') or row.get('report_date')} | {_fmt(row.get('roe_weighted'), 2, '%')} | "
- f"{_fmt(row.get('gross_margin'), 2, '%')} | {_fmt(row.get('debt_asset_ratio'), 2, '%')} | "
- f"{_fmt(float(row['revenue']) / 1e8 if row.get('revenue') is not None else None, 1, '亿')} | "
- f"{_fmt(float(row['parent_net_profit']) / 1e8 if row.get('parent_net_profit') is not None else None, 1, '亿')} | "
- f"{_fmt(float(row['operating_cash_flow']) / 1e8 if row.get('operating_cash_flow') is not None else None, 1, '亿')} | "
- f"{_fmt(float(row['free_cash_flow_lite']) / 1e8 if row.get('free_cash_flow_lite') is not None else None, 1, '亿')} |"
- )
- lines.extend([
- "",
- f"财务主线是“高盈利、低杠杆、增长换挡”:最新报告期 ROE {_fmt(roe, 2, '%')} 与低负债率仍提供防守性,"
- f"但利润同比 {_fmt(profit_yoy, 2, '%')} 低于收入同比 {_fmt(revenue_yoy, 2, '%')},需要观察费用、产品结构和渠道政策是否继续侵蚀经营杠杆。",
- "",
- "## 四、治理与资本配置",
- "",
- f"- 经营现金流 {_fmt(float(ocf) / 1e8 if ocf is not None else None, 1, '亿元')},FCF-lite {_fmt(float(fcf) / 1e8 if fcf is not None else None, 1, '亿元')},隐含资本开支代理约 {_fmt(float(reinvestment) / 1e8 if reinvestment is not None else None, 1, '亿元')}。",
- f"- 年度投资现金流净额 {_fmt(float(net_invest) / 1e8 if net_invest is not None else None, 1, '亿元')},融资现金流净额 {_fmt(float(net_finance) / 1e8 if net_finance is not None else None, 1, '亿元')}。",
- f"- 2025 年拟现金分红 {_fmt(float(dividend_total) / 1e8 if dividend_total is not None else None, 1, '亿元')},约占归母净利润 {_fmt(payout_ratio, 2, '%')};分红与回购共同构成股东回报,但仍需与长期资本开支统筹评估。",
- "- 这些净额说明现金流向,不直接等同于分红、回购或价值创造;投委会仍要求把分红、回购、重大投资与关联交易拆开审阅。",
- "",
- "## 五、估值与情景分析",
- "",
- "| 情景 | 估值锚 | 对应价格 | 相对现价 | 含义 |",
- "|---|---:|---:|---:|---|",
- ])
- labels = {15: "盈利承压/估值收缩", 18: "增长平稳/质量溢价收敛", 22: "盈利韧性/质量溢价维持"}
- for multiple in (15, 18, 22):
- scenario = _metric(pack, "C6", f"scenario_price_{multiple}x_pe")
- relation = ((scenario / price - 1) * 100) if scenario is not None and price else None
- lines.append(f"| {labels[multiple]} | {multiple}x FY EPS | {_fmt(scenario, 2, '元')} | {_fmt(relation, 2, '%')} | 敏感性测试,不是目标价 |")
- lines.extend(_expectation_valuation_lines(pack))
- lines.extend([
- "",
- f"==估值判断== 现价约 {_fmt(pe, 2, 'x')} 静态 PE;静态倍数只描述历史盈利。"
- "投资分歧应落到当前市值隐含的未来利润、正向经营模型能否覆盖该门槛,以及剩余价值是否有独立证据支持。",
- f"交易实现采用 100 万元订单情景,估算往返成本 {_fmt(execution_cost_100w, 2, 'bps')};已计价差、佣金、经手/过户费、卖出印花税和波动率冲击,仍需用用户真实费率与成交回报校准。",
- "",
- "## 六、投委会审议",
- "",
- "| 委员框架 | 基于当前数据的判断 | 主要保留意见 |",
- "|---|---|---|",
- ])
- views = {
- "buffett": ("高毛利、低杠杆和强 FCF 符合优质特许经营特征;价格已可讨论,但安全边际不算宽。", "需用批价、渠道库存和长期 owner earnings 验证护城河。"),
- "munger": ("商业模式简单、现金创造强,错误风险主要来自把过去的品牌强度线性外推。", "机会成本与治理激励需和其他高质量资产比较。"),
- "duan_yongping": ("好生意特征仍在,增长换挡不必然改变长期价值。", "合理价格取决于消费者心智和渠道秩序是否稳定。"),
- "zhang_kun": ("长期现金流质量较高,利润率与资本回报仍具稀缺性。", "利润增速弱于收入,需确认竞争格局与自由现金流可持续性。"),
- "graham": (f"{_fmt(pe, 2, 'x')} PE 并非传统深度价值区,15x 情景仍有下行空间。", "资产下行保护弱于盈利与品牌保护,不能只看 PB。"),
- "dalio": (f"60 日收益 {_fmt(return_60d, 2, '%')}、ATR {_fmt(atr, 2, '%')},组合层面仍需考虑消费风格和利率敏感性。", "单一优质资产也不能替代风险预算与分散化。"),
- "klarman": (f"现价对应 {_fmt(pe, 2, 'x')} 静态 PE,绝对回报取决于盈利兑现与催化,而非相对排名。", "15x 情景的永久损失风险仍需显式计入。"),
- "lynch": (f"营收/利润同比 {_fmt(revenue_yoy, 2, '%')} / {_fmt(profit_yoy, 2, '%')},增长故事正进入兑现检验。", "需解释产品、渠道和费用为何造成利润慢于收入。"),
- "o_neil": (f"增长尚未加速,5/20/60 日收益 {_fmt(return_5d, 2, '%')} / {_fmt(return_20d, 2, '%')} / {_fmt(return_60d, 2, '%')} 也未形成一致强势。", "缺少盈利加速与趋势共振,不宜只凭品牌质量追价。"),
- "wood": ("品牌与渠道数字化能够支持长期创新,但现阶段核心仍是成熟业务现金流。", "研发、渗透率和新增量尚不足以支持非线性增长假设。"),
- "soros": ("20 日回升与 60 日回撤并存,价格反馈尚未形成单向反身性。", "政策、批价与市场预期变化可能快速改变估值中枢。"),
- "livermore": (f"短线趋势尚未确认,ATR {_fmt(atr, 2, '%')} 要求先定义风险点。", "基本面优质不能替代价格确认和止损纪律。"),
- "minervini": (f"利润增速 {_fmt(profit_yoy, 2, '%')} 与 60 日收益 {_fmt(return_60d, 2, '%')} 尚不满足强势成长模板。", "等待盈利加速、相对强度和量价收缩后再评估入场质量。"),
- "simons": ("5/20/60 日与 ATR 已形成可复核样本,但仍只是单标的短窗口统计。", "需要更长样本、因子基准和交易成本后才能声称概率优势。"),
- "feng_liu": (f"市场已用 {_fmt(return_60d, 2, '%')} 的 60 日变化重定价增长预期,赔率有所改善。", "需要批价、渠道或利润率的边际改善验证认知差。"),
+ directional_claims = [claim for claim in claims if claim.get("direction") != "neutral"]
+ lines.extend(_claim_lines(directional_claims or claims[:3]))
+ sections = {
+ 2: "## 二、行情、商业质量与核心矛盾",
+ 3: "## 三、财务质量、增长与现金流",
+ 4: "## 四、治理与资本配置",
+ 5: "## 五、估值与情景分析",
+ 6: "## 六、投委会审议",
+ 7: "## 七、风险、催化剂与跟踪指标",
}
- for lens_id, opinion in opinions.items():
- view, reservation = views.get(lens_id, ("质量与估值需要共同过关。", "等待下一期 Evidence 更新。"))
- consumed = {item["metric"]: item.get("value") for item in opinion.get("metric_analyses") or []}
- cross_cutting_metrics = []
- if consumed.get("parent_net_margin_pct") is not None:
- cross_cutting_metrics.append(f"净利率 {_fmt(consumed['parent_net_margin_pct'], 2, '%')}")
- if consumed.get("operating_cash_conversion_pct") is not None:
- cross_cutting_metrics.append(f"经营现金转化 {_fmt(consumed['operating_cash_conversion_pct'], 2, '%')}")
- if consumed.get("shareholder_payout_ratio_pct") is not None:
- cross_cutting_metrics.append(f"股东派现率 {_fmt(consumed['shareholder_payout_ratio_pct'], 2, '%')}")
- if consumed.get("execution_round_trip_cost_100w_bps") is not None:
- cross_cutting_metrics.append(f"100万元往返成本 {_fmt(consumed['execution_round_trip_cost_100w_bps'], 2, 'bps')}")
- cross_cutting = "、".join(cross_cutting_metrics) + ";" if cross_cutting_metrics else ""
- lines.append(f"| {opinion['lens_name']} | {cross_cutting}{view} | {reservation} |")
- lines.extend([
- "",
- "**投委会共识**:质量和现金流可以支持继续研究,但估值并未给出无条件行动信号。 ",
- "**核心分歧**:长期质量、保守估值、增长兑现与价格纪律的权重不同;最终动作取决于用户研究问题和本轮入选委员的共同约束。",
- "",
- "## 七、风险、催化剂与跟踪指标",
- "",
- "| 类型 | 需要跟踪的可证伪指标 |",
- "|---|---|",
- "| 经营风险 | 营收与利润增速继续背离、毛利率/ROE 同步下行、经营现金转化下降 |",
- f"| 护城河风险 | 系列酒收入同比 {_fmt(series_revenue_yoy, 2, '%')}、库存量同比 {_fmt(inventory_yoy, 2, '%')};继续跟踪批价、核心产品需求与份额变化 |",
- f"| 费用风险 | 销售费用同比 {_fmt(sales_expense_yoy, 2, '%')};若费用高增长未换来收入和渠道改善,将继续压制经营杠杆 |",
- "| 估值风险 | 盈利预期下修叠加估值中枢向 15x–18x 收缩 |",
- "| 治理风险 | 分红回购、重大资本开支、关联交易和管理层变动偏离股东利益 |",
- f"| 市场风险 | 60 日趋势 {_fmt(return_60d, 2, '%')} 与 ATR {_fmt(atr, 2, '%')} 继续恶化,触发组合风险预算复核 |",
- ])
- if events:
- lines.extend(["", "**当前事件线索**:" + ";".join(events[:3]) + "。事件只作为跟踪线索,不单独构成投资结论。"])
- monitoring = (pack.get("expectation_model") or {}).get("monitoring") or []
- if monitoring:
- lines.extend(["", "**观点变化触发器**:", "", "| 指标 | 基准 | 下次检查 | 改变观点的条件 |", "|---|---:|---|---|"])
- for item in monitoring:
- lines.append(
- f"| {item['metric']} | {_fmt(item.get('baseline'))} | {item.get('next_check_date') or '待定'} | "
- f"{item['view_change_condition']} |"
- )
- lines.extend([
- "",
- "## 八、投委会结论与条件化动作",
- "",
- f"**结论:{action_label}。** 当前数据更支持“高质量、增长换挡、估值合理但安全边际一般”,而不是简单的买入/卖出标签。",
- "",
- "| 情形 | 条件化动作 |",
- "|---|---|",
- "| 已有长期仓位 | 继续持有并用季报、批价、渠道库存和现金流验证,不因短期风格跑输机械减仓 |",
- "| 准备新增仓位 | 先设定组合上限;等待盈利韧性确认,或价格进入更有吸引力的 15x–18x 情景后分批复核 |",
- "| 经营证伪 | 若毛利率、ROE、现金转化与批价同步恶化,重估护城河并收缩风险预算 |",
- "| 估值上行 | 若价格接近或超过 22x 情景而盈利未上修,提高收益兑现与机会成本要求 |",
- "",
- "### 后续跟踪重点",
- "",
- "- 分红与回购是否持续覆盖自由现金流,并保持资本配置纪律。",
- "- 直销与批发渠道收入、批价、库存及核心产品需求变化。",
- "- 毛利率、ROE、净利率和经营现金转化是否同步企稳。",
- "- 价格进入不同估值情景时,盈利预期是否发生相同方向变化。",
- "",
- DISCLAIMER,
- "股市有风险,投资需谨慎。",
- "",
- ])
+ for section, heading in sections.items():
+ lines.extend([heading, ""])
+ lines.extend(_claim_lines(grouped[section]))
+ lines.extend(["## 八、投委会结论与条件化动作", ""])
+ if publication_status == "block_action":
+ action_reasons = [
+ str(issue["reason"])
+ for issue in (committee.get("safety_gate") or {}).get("issues") or []
+ if issue.get("decision") == "block_action"
+ ]
+ lines.extend(
+ [
+ "估值或交易执行行动被阻断;本报告不生成具体仓位、价格区间或买卖指令。",
+ *(f"- {reason}" for reason in action_reasons),
+ "",
+ ]
+ )
+ else:
+ lines.extend(["以上可发布命题构成本轮条件化研究结论,不生成无条件买卖指令。", ""])
+ lines.extend([DISCLAIMER, "股市有风险,投资需谨慎。", ""])
return "\n".join(lines)
@@ -632,6 +535,7 @@ def build_research_workspace(
snapshot = freeze_company_evidence(pack)
opinions = build_company_lens_opinions(snapshot, lenses=lenses, research_question=research_question)
committee = synthesize_company_committee(snapshot, opinions)
+ audit_artifacts = build_claim_audit_artifacts(snapshot, opinions, committee)
evidence_json = json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n"
opinions_json = json.dumps(opinions, ensure_ascii=False, indent=2) + "\n"
committee_json = json.dumps(committee, ensure_ascii=False, indent=2) + "\n"
@@ -645,6 +549,22 @@ def build_research_workspace(
"committee_review": ("05-committee-review.md", _committee_review(pack, committee)),
"decision_memo": ("06-decision-memo.md", _decision_memo(pack, committee)),
"institutional_report": ("07-institutional-report.md", _institutional_report(pack, changes, opinions, committee)),
+ "evidence_manifest": (
+ "evidence_manifest.json",
+ json.dumps(audit_artifacts["evidence_manifest"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "claim_ledger": (
+ "claim_ledger.json",
+ json.dumps(audit_artifacts["claim_ledger"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "coverage_report": (
+ "coverage_report.json",
+ json.dumps(audit_artifacts["coverage_report"], ensure_ascii=False, indent=2) + "\n",
+ ),
+ "unpublished_claims": (
+ "unpublished_claims.json",
+ json.dumps(audit_artifacts["unpublished_claims"], ensure_ascii=False, indent=2) + "\n",
+ ),
}
previous_artifacts = previous_manifest.get("artifacts") or {}
artifacts = {
@@ -662,7 +582,11 @@ def build_research_workspace(
"committee_members": list(opinions),
"created_at": previous_manifest.get("created_at") or now,
"updated_at": now,
- "status": "ready_for_analysis" if committee["action"] == "manual_review" else "evidence_insufficient",
+ "status": {
+ "block_report": "blocked_report",
+ "block_action": "action_blocked",
+ }.get(committee["publication_status"], "ready_for_analysis"),
+ "publication_status": committee["publication_status"],
"stages": {
"scope": "complete",
"research_plan": "complete",
diff --git a/tests/test_company_lens.py b/tests/test_company_lens.py
index d7aac0d..7898f7d 100644
--- a/tests/test_company_lens.py
+++ b/tests/test_company_lens.py
@@ -1,5 +1,6 @@
from copy import deepcopy
+from stock_analysis.committee_selection import relevant_research_modules
from stock_analysis.company_lens import (
build_company_lens_opinions,
freeze_company_evidence,
@@ -15,7 +16,15 @@ def _pack():
available = code in {"C2", "C3", "C5", "C6", "C7", "C8"}
modules[code] = {
"available": available,
- "evidence": [{"evidence_id": f"{code}:fixture", "metric": f"metric_{index}", "value": index, "validation_status": "accepted"}] if available else [],
+ "evidence": [{
+ "evidence_id": f"{code}:fixture",
+ "metric": f"metric_{index}",
+ "value": index,
+ "period": "20260710",
+ "validation_status": "accepted",
+ "source_type": "primary_disclosure",
+ "source": f"fixture-filing-{code}",
+ }] if available else [],
"gaps": [] if available else [f"{code} gap"],
}
return {
@@ -74,6 +83,9 @@ def test_committee_selection_understands_plain_english_investor_questions():
assert {"buffett", "munger"} <= set(quality)
assert {"simons", "minervini"} <= set(trading)
+ assert relevant_research_modules("business model", asset_type="company") == ("C1",)
+ assert relevant_research_modules("product mandate", asset_type="fund") == ("F1",)
+ assert relevant_research_modules("无法映射的专门问题", asset_type="company") == ()
def test_every_selected_lens_consumes_new_business_quality_metrics():
@@ -99,3 +111,143 @@ def test_every_selected_lens_consumes_new_business_quality_metrics():
assert all(item["interpretation"] for item in opinion["metric_analyses"])
assert committee["evidence_consumption_audit"]["parent_net_margin_pct"] == list(opinions)
assert committee["evidence_consumption_audit"]["operating_cash_conversion_pct"] == list(opinions)
+
+
+def _add_cash_quality_metrics(pack, *, cash_flow_yoy, receivables_yoy, revenue_yoy):
+ pack["modules"]["C2"]["evidence"].extend(
+ [
+ {
+ "evidence_id": "C2:ocf-yoy",
+ "metric": "operating_cash_flow_yoy_pct",
+ "value": cash_flow_yoy,
+ "validation_status": "accepted",
+ },
+ {
+ "evidence_id": "C2:ar-yoy",
+ "metric": "accounts_receivable_yoy_pct",
+ "value": receivables_yoy,
+ "validation_status": "accepted",
+ },
+ ]
+ )
+ pack["modules"]["C3"]["evidence"].append(
+ {
+ "evidence_id": "C3:revenue-yoy",
+ "metric": "revenue_yoy_pct",
+ "value": revenue_yoy,
+ "validation_status": "accepted",
+ }
+ )
+
+
+def test_removing_nonblocking_market_share_does_not_make_supported_direction_conservative():
+ full = _pack()
+ _add_cash_quality_metrics(full, cash_flow_yoy=12, receivables_yoy=4, revenue_yoy=8)
+ full["modules"]["C4"] = {
+ "available": True,
+ "evidence": [
+ {
+ "evidence_id": "C4:market-share",
+ "metric": "market_share_pct",
+ "value": 25,
+ "period": "20260710",
+ "validation_status": "accepted",
+ "source_type": "primary_disclosure",
+ "source": "fixture-market-share-filing",
+ }
+ ],
+ "gaps": [],
+ }
+ narrowed = deepcopy(full)
+ narrowed["modules"]["C4"] = {
+ "available": False,
+ "evidence": [],
+ "gaps": ["market_share"],
+ }
+
+ full_snapshot = freeze_company_evidence(full)
+ full_opinions = build_company_lens_opinions(full_snapshot)
+ full_committee = synthesize_company_committee(full_snapshot, full_opinions)
+ narrowed_snapshot = freeze_company_evidence(narrowed)
+ narrowed_opinions = build_company_lens_opinions(narrowed_snapshot)
+ narrowed_committee = synthesize_company_committee(narrowed_snapshot, narrowed_opinions)
+
+ assert any(
+ claim["claim"] == "增长的现金实现质量保持稳定" and claim["direction"] == "bullish"
+ for claim in full_committee["publishable_claims"]
+ )
+ assert any(
+ claim["claim"] == "增长的现金实现质量保持稳定" and claim["direction"] == "bullish"
+ for claim in narrowed_committee["publishable_claims"]
+ )
+ assert not any(
+ claim["direction"] == "bearish" and "market_share" in str(claim)
+ for claim in narrowed_committee["publishable_claims"]
+ )
+
+
+def test_real_cash_quality_deterioration_remains_publishable_bearish_evidence():
+ pack = _pack()
+ _add_cash_quality_metrics(pack, cash_flow_yoy=-15, receivables_yoy=25, revenue_yoy=5)
+ snapshot = freeze_company_evidence(pack)
+ committee = synthesize_company_committee(snapshot, build_company_lens_opinions(snapshot))
+ claim = next(
+ item for item in committee["publishable_claims"]
+ if item["claim"] == "增长的现金实现质量下降"
+ )
+
+ assert claim["direction"] == "bearish"
+ assert set(claim["evidence_ids"]) == {"C2:ocf-yoy", "C2:ar-yoy", "C3:revenue-yoy"}
+
+
+def test_cash_quality_calculation_requires_comparable_periods():
+ pack = _pack()
+ _add_cash_quality_metrics(pack, cash_flow_yoy=-15, receivables_yoy=25, revenue_yoy=5)
+ pack["modules"]["C3"]["evidence"][-1]["period"] = "2025Q4"
+ snapshot = freeze_company_evidence(pack)
+ committee = synthesize_company_committee(snapshot, build_company_lens_opinions(snapshot))
+
+ assert not any(
+ claim["claim"] == "增长的现金实现质量下降"
+ for claim in committee["publishable_claims"]
+ )
+
+
+def test_every_publishable_company_claim_is_conditionally_verifiable():
+ snapshot = freeze_company_evidence(_pack())
+ committee = synthesize_company_committee(snapshot, build_company_lens_opinions(snapshot))
+ known_ids = {
+ item["evidence_id"]
+ for section in snapshot["evidence"]["modules"].values()
+ for item in section["evidence"]
+ }
+
+ assert committee["publishable_claims"]
+ for claim in committee["publishable_claims"]:
+ assert claim["evidence_ids"]
+ assert set(claim["evidence_ids"]) <= known_ids
+ assert claim["applicable_period"]
+ assert claim["conditions"]
+ assert claim["invalidators"]
+ assert claim["claim_status"] in {"supported", "strongly_supported"}
+
+
+def test_question_with_only_unrelated_supported_claims_blocks_report():
+ pack = _pack()
+ for code, section in pack["modules"].items():
+ section["available"] = code == "C3"
+ section["evidence"] = section["evidence"] if code == "C3" else []
+ section["gaps"] = [] if code == "C3" else [f"{code} gap"]
+ snapshot = freeze_company_evidence(pack)
+ opinions = build_company_lens_opinions(
+ snapshot,
+ research_question="商业模式如何创造价值?",
+ )
+ committee = synthesize_company_committee(snapshot, opinions)
+
+ assert committee["publishable_claims"] == []
+ assert committee["publication_status"] == "block_report"
+ assert any(
+ issue["code"] == "NO_SUPPORTED_CLAIMS"
+ for issue in committee["safety_gate"]["issues"]
+ )
diff --git a/tests/test_company_workflows.py b/tests/test_company_workflows.py
index 680060b..b603f7c 100644
--- a/tests/test_company_workflows.py
+++ b/tests/test_company_workflows.py
@@ -104,6 +104,8 @@ def test_company_evidence_has_c1_to_c8_and_explicit_gaps(monkeypatch):
assert pack["modules"]["C4"]["available"] is True
assert pack["modules"]["C5"]["available"] is True
assert pack["_meta"]["coverage"] == 100.0
+ assert pack["_meta"]["identity_validation"]["status"] == "matched"
+ assert pack["_meta"]["publication_cutoff_audit"]["violations"] == []
primary_metrics = {
item["metric"]
for code in ("C1", "C4", "C5", "C7", "C8")
diff --git a/tests/test_fund_research.py b/tests/test_fund_research.py
index aedfbca..d83c466 100644
--- a/tests/test_fund_research.py
+++ b/tests/test_fund_research.py
@@ -18,7 +18,10 @@ def _pack():
"evidence_id": f"F{index}:fixture",
"metric": f"metric_{index}",
"value": index,
+ "period": "20260717",
"validation_status": "accepted",
+ "source_type": "primary_disclosure",
+ "source": f"fixture-filing-F{index}",
}
] if index != 5 else [],
"gaps": ["缺少成分股估值"] if index == 5 else [],
@@ -85,6 +88,13 @@ def test_fund_workspace_uses_institutional_skeleton(tmp_path):
assert "sha256:" not in report
assert "审计与待核验事项" not in report
assert json.loads((workspace / "02-frozen-fund-evidence.json").read_text(encoding="utf-8"))["snapshot_id"]
+ for filename in (
+ "evidence_manifest.json",
+ "claim_ledger.json",
+ "coverage_report.json",
+ "unpublished_claims.json",
+ ):
+ assert json.loads((workspace / filename).read_text(encoding="utf-8"))
def test_research_cli_routes_fund_asset_type(monkeypatch, tmp_path, capsys):
@@ -151,11 +161,11 @@ def test_official_fund_contract_and_index_methodology_are_structured():
def test_simons_reservation_reconciles_with_index_history_and_execution_cost_evidence(tmp_path):
pack = _pack()
pack["modules"]["F5"]["evidence"] = [
- {"evidence_id": "F5:index-pe", "metric": "index_pe_calculation_share", "value": 108.15, "validation_status": "accepted"},
+ {"evidence_id": "F5:index-pe", "metric": "index_pe_calculation_share", "value": 108.15, "period": "20260717", "validation_status": "accepted", "source_type": "primary_disclosure", "source": "fixture-index-filing"},
]
pack["modules"]["F6"]["evidence"] = [
- {"evidence_id": "F6:index-sample", "metric": "index_history_sample_size", "value": 90, "validation_status": "accepted"},
- {"evidence_id": "F6:index-vol", "metric": "index_annualized_volatility_60d_pct", "value": 60.0, "validation_status": "accepted"},
+ {"evidence_id": "F6:index-sample", "metric": "index_history_sample_size", "value": 90, "period": "20260717", "validation_status": "accepted", "source_type": "primary_disclosure", "source": "fixture-index-history"},
+ {"evidence_id": "F6:index-vol", "metric": "index_annualized_volatility_60d_pct", "value": 60.0, "period": "20260717", "validation_status": "accepted", "source_type": "primary_disclosure", "source": "fixture-index-history"},
]
pack["modules"]["F7"]["evidence"] = [
{"evidence_id": "F7:cost-status", "metric": "execution_cost_model_status", "value": "scenario_complete", "validation_status": "conditional"},
@@ -165,8 +175,35 @@ def test_simons_reservation_reconciles_with_index_history_and_execution_cost_evi
pack, root=tmp_path, research_question="量化、指数趋势、交易成本和回撤"
)
report = (workspace / manifest["artifacts"]["institutional_report"]["path"]).read_text(encoding="utf-8")
- simons_row = next(line for line in report.splitlines() if line.startswith("| 西蒙斯 |"))
+ ledger = json.loads((workspace / "claim_ledger.json").read_text(encoding="utf-8"))
+ published_ids = {
+ evidence_id
+ for claim in ledger["publishable_claims"]
+ for evidence_id in claim["evidence_ids"]
+ }
+
+ assert "缺少标的指数日线与完整交易成本模型" not in report
+ assert "index_history_sample_size为90" in report
+ assert {"F6:index-sample", "F6:index-vol"} <= published_ids
+ assert "F5:index-pe" not in published_ids
+ assert "F7:cost" not in published_ids
- assert "缺少标的指数日线与完整交易成本模型" not in simons_row
- assert "指数日线样本 90" in simons_row
- assert "100万元往返成本 8.20bps" in simons_row
+
+def test_fund_question_with_only_unrelated_supported_claims_blocks_report():
+ pack = _pack()
+ for code, section in pack["modules"].items():
+ section["available"] = code == "F8"
+ section["evidence"] = section["evidence"] if code == "F8" else []
+ section["gaps"] = [] if code == "F8" else [f"{code} gap"]
+ snapshot = freeze_fund_evidence(pack)
+ committee, _ = synthesize_fund_committee(
+ snapshot,
+ research_question="产品契约如何约束复制方式?",
+ )
+
+ assert committee["publishable_claims"] == []
+ assert committee["publication_status"] == "block_report"
+ assert any(
+ issue["code"] == "NO_SUPPORTED_CLAIMS"
+ for issue in committee["safety_gate"]["issues"]
+ )
diff --git a/tests/test_global_company_evidence.py b/tests/test_global_company_evidence.py
index a2e6233..02c039f 100644
--- a/tests/test_global_company_evidence.py
+++ b/tests/test_global_company_evidence.py
@@ -97,3 +97,7 @@ def test_us_company_pack_prefers_sec_and_emits_primary_reach_requests(monkeypatc
assert pack["modules"]["C2"]["available"] is True
assert any(item.get("confidence") == "primary" for item in pack["modules"]["C2"]["evidence"])
assert {row["module"] for row in pack["_meta"]["primary_evidence_requests"]} >= {"C1", "C4", "C5", "C7", "C8"}
+ assert pack["_meta"]["identity_validation"]["status"] == "matched"
+ assert pack["_meta"]["basis_conflicts"] == []
+ assert pack["_meta"]["primary_conflicts"] == []
+ assert pack["_meta"]["publication_cutoff_audit"]["violations"] == []
diff --git a/tests/test_research_claims.py b/tests/test_research_claims.py
new file mode 100644
index 0000000..d9a72e0
--- /dev/null
+++ b/tests/test_research_claims.py
@@ -0,0 +1,621 @@
+import pytest
+
+from stock_analysis.research_claims import (
+ MissingEvidenceEffect,
+ PublicationDecision,
+ ResearchClaim,
+ SupportStatus,
+ build_claim_audit_artifacts,
+ build_evidence_integrity_audit,
+ claim_source_ids,
+ compile_metric_claims,
+ evaluate_safety_gate,
+ partition_claims,
+ publication_decision,
+ validate_claim_evidence_ids,
+)
+
+PRIMARY_SOURCE_ID = claim_source_ids(
+ {
+ "source_type": "issuer_primary_disclosure",
+ "url": "https://example.test/annual-report.pdf",
+ }
+)[0][0]
+SECONDARY_SOURCE_ID = claim_source_ids(
+ {
+ "source_type": "structured_public_disclosure",
+ "url": "https://example.test/structured-disclosure",
+ }
+)[1][0]
+SECONDARY_SOURCE_ID_2 = claim_source_ids(
+ {
+ "source_type": "news_sample",
+ "url": "https://example.test/news-sample",
+ }
+)[1][0]
+
+
+def _claim(**overrides):
+ values = {
+ "claim_id": "C-QUALITY-001",
+ "claim": "已披露业务的现金实现质量保持稳定",
+ "direction": "bullish",
+ "scope": "已披露业务",
+ "evidence_ids": ("EV-OCF-001", "EV-REVENUE-001"),
+ "claim_status": SupportStatus.SUPPORTED,
+ "applicable_period": "2026Q1",
+ "conditions": ("经营现金流口径保持一致",),
+ "invalidators": ("经营现金流转负",),
+ "missing_evidence": (),
+ "missing_evidence_effect": MissingEvidenceEffect.NO_MATERIAL_EFFECT,
+ "primary_source_ids": (PRIMARY_SOURCE_ID,),
+ "secondary_source_ids": (SECONDARY_SOURCE_ID,),
+ "calculation_input_evidence_ids": (),
+ "report_blocking_reasons": (),
+ }
+ values.update(overrides)
+ return ResearchClaim(**values)
+
+
+def test_support_status_has_exactly_five_discrete_states():
+ assert {status.value for status in SupportStatus} == {
+ "strongly_supported",
+ "supported",
+ "unsupported",
+ "speculative",
+ "conflicted_unresolved",
+ }
+
+
+def test_missing_evidence_effect_has_exactly_four_states():
+ assert {effect.value for effect in MissingEvidenceEffect} == {
+ "no_material_effect",
+ "narrows_scope",
+ "blocks_claim",
+ "blocks_action",
+ }
+
+
+def test_publication_decision_has_exactly_five_outcomes():
+ assert set(PublicationDecision.__members__) == {
+ "PUBLISH",
+ "PUBLISH_NARROWED",
+ "AUDIT_ONLY",
+ "BLOCK_ACTION",
+ "BLOCK_REPORT",
+ }
+
+
+@pytest.mark.parametrize(
+ ("claim_status", "expected"),
+ [
+ (SupportStatus.STRONGLY_SUPPORTED, PublicationDecision.PUBLISH),
+ (SupportStatus.SUPPORTED, PublicationDecision.PUBLISH),
+ (SupportStatus.UNSUPPORTED, PublicationDecision.AUDIT_ONLY),
+ (SupportStatus.SPECULATIVE, PublicationDecision.AUDIT_ONLY),
+ (SupportStatus.CONFLICTED_UNRESOLVED, PublicationDecision.AUDIT_ONLY),
+ ],
+)
+def test_only_supported_statuses_are_publishable(claim_status, expected):
+ assert publication_decision(_claim(claim_status=claim_status)) is expected
+
+
+@pytest.mark.parametrize(
+ ("overrides", "expected"),
+ [
+ (
+ {
+ "primary_source_ids": (),
+ "secondary_source_ids": (SECONDARY_SOURCE_ID, SECONDARY_SOURCE_ID_2),
+ },
+ PublicationDecision.PUBLISH,
+ ),
+ (
+ {
+ "evidence_ids": ("EV-OCF-001",),
+ "primary_source_ids": (PRIMARY_SOURCE_ID,),
+ "secondary_source_ids": (),
+ "calculation_input_evidence_ids": ("EV-OCF-001",),
+ },
+ PublicationDecision.PUBLISH,
+ ),
+ (
+ {
+ "evidence_ids": ("EV-OCF-001",),
+ "primary_source_ids": (PRIMARY_SOURCE_ID,),
+ "secondary_source_ids": (),
+ "calculation_input_evidence_ids": (),
+ },
+ PublicationDecision.AUDIT_ONLY,
+ ),
+ ],
+)
+def test_strongly_supported_requires_two_sources_or_primary_plus_calculation(overrides, expected):
+ claim = _claim(claim_status=SupportStatus.STRONGLY_SUPPORTED, **overrides)
+
+ assert publication_decision(claim) is expected
+
+
+@pytest.mark.parametrize(
+ ("overrides", "expected"),
+ [
+ (
+ {
+ "evidence_ids": ("EV-OCF-001",),
+ "primary_source_ids": (PRIMARY_SOURCE_ID,),
+ "secondary_source_ids": (),
+ },
+ PublicationDecision.PUBLISH,
+ ),
+ (
+ {
+ "primary_source_ids": (),
+ "secondary_source_ids": (SECONDARY_SOURCE_ID, SECONDARY_SOURCE_ID_2),
+ },
+ PublicationDecision.PUBLISH,
+ ),
+ (
+ {
+ "evidence_ids": ("EV-OCF-001",),
+ "primary_source_ids": (),
+ "secondary_source_ids": (),
+ "calculation_input_evidence_ids": ("EV-OCF-001",),
+ },
+ PublicationDecision.PUBLISH,
+ ),
+ (
+ {
+ "evidence_ids": ("EV-OCF-001",),
+ "primary_source_ids": (),
+ "secondary_source_ids": (SECONDARY_SOURCE_ID,),
+ },
+ PublicationDecision.AUDIT_ONLY,
+ ),
+ ],
+)
+def test_supported_requires_primary_two_secondary_or_reproducible_calculation(overrides, expected):
+ claim = _claim(**overrides)
+
+ assert publication_decision(claim) is expected
+
+
+def test_two_evidence_items_from_one_source_are_not_strongly_supported():
+ claim = _claim(
+ claim_status=SupportStatus.STRONGLY_SUPPORTED,
+ primary_source_ids=(),
+ secondary_source_ids=(SECONDARY_SOURCE_ID,),
+ )
+
+ assert len(claim.evidence_ids) == 2
+ assert publication_decision(claim) is PublicationDecision.AUDIT_ONLY
+
+
+def test_not_primary_source_type_does_not_count_as_primary():
+ evidence = {
+ "trade_date": "20260710",
+ "modules": {
+ "C2": {
+ "available": True,
+ "evidence": [
+ {
+ "evidence_id": "EV-NOT-PRIMARY",
+ "metric": "operating_cash_flow",
+ "value": 100,
+ "source": "derived-fixture",
+ "source_type": "not_primary",
+ "confidence": "not_primary",
+ "validation_status": "accepted",
+ }
+ ],
+ "gaps": [],
+ }
+ },
+ }
+
+ publishable, unpublished = compile_metric_claims(evidence, claim_prefix="company")
+
+ assert publishable == []
+ assert unpublished[0].primary_source_count == 0
+ assert publication_decision(unpublished[0]) is PublicationDecision.AUDIT_ONLY
+
+
+def test_reproducible_claim_requires_explicit_input_evidence_ids():
+ claim = _claim(
+ evidence_ids=("EV-OCF-001",),
+ primary_source_ids=(),
+ secondary_source_ids=(),
+ calculation_input_evidence_ids=(),
+ )
+
+ assert claim.has_reproducible_calculation is False
+ assert publication_decision(claim) is PublicationDecision.AUDIT_ONLY
+
+
+def test_reproducible_claim_accepts_referenced_input_evidence_ids():
+ claim = _claim(
+ evidence_ids=("EV-OCF-001",),
+ primary_source_ids=(),
+ secondary_source_ids=(),
+ calculation_input_evidence_ids=("EV-OCF-001",),
+ )
+
+ assert claim.has_reproducible_calculation is True
+ assert publication_decision(claim) is PublicationDecision.PUBLISH
+
+
+@pytest.mark.parametrize(("input_period", "published"), [("2026Q1", True), ("2025Q4", False)])
+def test_compiled_calculation_requires_comparable_validated_inputs(input_period, published):
+ evidence = {
+ "trade_date": "20260710",
+ "modules": {
+ "C2": {
+ "available": True,
+ "evidence": [
+ {
+ "evidence_id": "EV-INPUT",
+ "metric": "revenue",
+ "period": input_period,
+ "scope": "consolidated",
+ "currency": "CNY",
+ "value": 100,
+ "source": "issuer-filing",
+ "source_type": "primary_disclosure",
+ "validation_status": "accepted",
+ },
+ {
+ "evidence_id": "EV-CALC",
+ "metric": "revenue_ratio",
+ "period": "2026Q1",
+ "scope": "consolidated",
+ "currency": "CNY",
+ "value": 1.2,
+ "formula": "revenue / baseline",
+ "calculation_input_evidence_ids": ["EV-INPUT"],
+ "validation_status": "accepted",
+ },
+ ],
+ "gaps": [],
+ }
+ },
+ }
+
+ publishable, unpublished = compile_metric_claims(evidence, claim_prefix="company")
+ published_metrics = {claim.claim for claim in publishable}
+ unpublished_metrics = {claim.claim for claim in unpublished}
+
+ assert ("revenue_ratio" in " ".join(published_metrics)) is published
+ assert ("revenue_ratio" in " ".join(unpublished_metrics)) is (not published)
+
+
+@pytest.mark.parametrize(
+ ("effect", "expected"),
+ [
+ (MissingEvidenceEffect.NO_MATERIAL_EFFECT, PublicationDecision.PUBLISH),
+ (MissingEvidenceEffect.NARROWS_SCOPE, PublicationDecision.PUBLISH_NARROWED),
+ (MissingEvidenceEffect.BLOCKS_CLAIM, PublicationDecision.AUDIT_ONLY),
+ (MissingEvidenceEffect.BLOCKS_ACTION, PublicationDecision.BLOCK_ACTION),
+ ],
+)
+def test_missing_evidence_effect_controls_publication_without_scoring(effect, expected):
+ claim = _claim(
+ missing_evidence=("分产品季度销量",),
+ missing_evidence_effect=effect,
+ )
+
+ assert publication_decision(claim) is expected
+
+
+def test_identity_conflict_blocks_the_entire_report():
+ claim = _claim(
+ claim_status=SupportStatus.STRONGLY_SUPPORTED,
+ report_blocking_reasons=("证券身份冲突",),
+ )
+
+ assert publication_decision(claim) is PublicationDecision.BLOCK_REPORT
+
+
+def test_missing_evidence_never_changes_a_supported_claim_to_bearish():
+ claim = _claim(
+ missing_evidence=("分产品季度销量",),
+ missing_evidence_effect=MissingEvidenceEffect.NARROWS_SCOPE,
+ )
+
+ assert publication_decision(claim) is PublicationDecision.PUBLISH_NARROWED
+ assert claim.direction == "bullish"
+
+
+def test_supported_claim_without_evidence_reference_is_audit_only():
+ claim = _claim(evidence_ids=())
+
+ assert publication_decision(claim) is PublicationDecision.AUDIT_ONLY
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [("applicable_period", ""), ("conditions", ()), ("invalidators", ())],
+)
+def test_supported_claim_without_verifiable_conditions_is_audit_only(field, value):
+ claim = _claim(**{field: value})
+
+ assert publication_decision(claim) is PublicationDecision.AUDIT_ONLY
+
+
+def test_claim_evidence_ids_must_exist_in_the_frozen_snapshot():
+ claim = _claim(evidence_ids=("EV-OCF-001", "EV-404"))
+
+ with pytest.raises(ValueError, match="EV-404"):
+ validate_claim_evidence_ids(claim, {"EV-OCF-001", "EV-REVENUE-001"})
+
+
+def test_claim_evidence_id_validation_accepts_known_ids():
+ claim = _claim()
+
+ validate_claim_evidence_ids(claim, {"EV-OCF-001", "EV-REVENUE-001"})
+
+
+def test_missing_company_valuation_inputs_block_action_not_report():
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {"C2": {"evidence": [], "gaps": []}},
+ "_meta": {},
+ }
+
+ gate = evaluate_safety_gate(evidence, [_claim().to_dict()], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_ACTION.value
+ assert {issue["code"] for issue in gate["issues"]} >= {
+ "PRICE_UNAVAILABLE",
+ "MARKET_CAP_UNAVAILABLE",
+ }
+
+
+def test_claim_level_blocks_action_propagates_to_the_report_gate():
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {
+ "C6": {
+ "evidence": [
+ {"metric": "market_quote", "value": 1500},
+ {"metric": "total_market_cap", "value": 1_800_000_000_000},
+ ],
+ "gaps": [],
+ },
+ "C7": {
+ "evidence": [{"metric": "execution_cost_model_status", "value": "scenario_complete"}],
+ "gaps": [],
+ },
+ },
+ "_meta": {},
+ }
+ claim = _claim(
+ missing_evidence_effect=MissingEvidenceEffect.BLOCKS_ACTION,
+ missing_evidence=("用户订单参数",),
+ ).to_dict()
+
+ gate = evaluate_safety_gate(evidence, [claim], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_ACTION.value
+ assert any(issue["code"] == "CLAIM_BLOCKS_ACTION" for issue in gate["issues"])
+
+
+def test_claim_level_blocks_report_propagates_separately_from_publishable_claims():
+ blocker = _claim(report_blocking_reasons=("证券身份冲突",))
+ partitioned = partition_claims([blocker])
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {
+ "C6": {
+ "evidence": [
+ {"metric": "market_quote", "value": 1500},
+ {"metric": "total_market_cap", "value": 1_800_000_000_000},
+ ],
+ "gaps": [],
+ }
+ },
+ "execution_cost_model": {"model_status": "scenario_complete"},
+ "_meta": {},
+ }
+
+ gate = evaluate_safety_gate(
+ evidence,
+ [_claim().to_dict()],
+ asset_type="company",
+ report_blockers=partitioned["report_blockers"],
+ )
+
+ assert partitioned["publishable_claims"] == []
+ assert partitioned["report_blockers"][0]["claim_id"] == blocker.claim_id
+ assert gate["decision"] == PublicationDecision.BLOCK_REPORT.value
+ assert any(issue["code"] == "CLAIM_BLOCKS_REPORT" for issue in gate["issues"])
+
+
+@pytest.mark.parametrize(
+ ("metric", "value", "validation_status"),
+ [
+ ("market_quote", -1, "accepted"),
+ ("market_quote", 1500, "expired"),
+ ("total_market_cap", -1, "accepted"),
+ ("total_market_cap", 1_800_000_000_000, "expired"),
+ ],
+)
+def test_invalid_or_expired_company_price_basis_blocks_action(metric, value, validation_status):
+ defaults = {
+ "market_quote": {"value": 1500, "period": "20260710", "validation_status": "accepted"},
+ "total_market_cap": {"value": 1_800_000_000_000, "period": "20260710", "validation_status": "accepted"},
+ }
+ defaults[metric].update(value=value, validation_status=validation_status)
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {
+ "C6": {
+ "evidence": [
+ {"metric": name, **payload}
+ for name, payload in defaults.items()
+ ],
+ "gaps": [],
+ }
+ },
+ "execution_cost_model": {"model_status": "scenario_complete"},
+ "_meta": {},
+ }
+
+ gate = evaluate_safety_gate(evidence, [_claim().to_dict()], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_ACTION.value
+
+
+def test_current_positive_company_valuation_basis_does_not_block_action():
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {
+ "C6": {
+ "evidence": [
+ {"metric": "market_quote", "value": 1500, "period": "20260710", "validation_status": "accepted"},
+ {"metric": "total_market_cap", "value": 1_800_000_000_000, "period": "20260710", "validation_status": "accepted"},
+ ],
+ "gaps": [],
+ }
+ },
+ "execution_cost_model": {"model_status": "scenario_complete"},
+ "_meta": {},
+ }
+
+ gate = evaluate_safety_gate(evidence, [_claim().to_dict()], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.PUBLISH.value
+
+
+def test_missing_fund_price_blocks_action():
+ evidence = {
+ "symbol": "512480",
+ "trade_date": "20260710",
+ "price_volume": {"rows": []},
+ "premium_discount": {"latest": {}},
+ "execution_cost_model": {"model_status": "scenario_complete"},
+ "_meta": {},
+ }
+
+ gate = evaluate_safety_gate(evidence, [_claim().to_dict()], asset_type="fund")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_ACTION.value
+ assert any(issue["code"] == "PRICE_UNAVAILABLE" for issue in gate["issues"])
+
+
+def test_explicit_identity_conflict_blocks_report():
+ evidence = {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {},
+ "_meta": {"identity_validation": {"status": "conflict"}},
+ }
+
+ gate = evaluate_safety_gate(evidence, [_claim().to_dict()], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_REPORT.value
+
+
+def test_production_integrity_audit_detects_explicit_major_exceptions():
+ modules = {
+ "C2": {
+ "evidence": [
+ {
+ "evidence_id": "EV-A",
+ "metric": "revenue",
+ "period": "2025FY",
+ "value": 100,
+ "currency": "CNY",
+ "source_type": "primary_disclosure",
+ "source": "filing-a",
+ "validation_status": "accepted",
+ "published_at": "20260709",
+ },
+ {
+ "evidence_id": "EV-B",
+ "metric": "revenue",
+ "period": "2025FY",
+ "value": 101,
+ "currency": "USD",
+ "source_type": "primary_disclosure",
+ "source": "filing-b",
+ "validation_status": "accepted",
+ "published_at": "20260711",
+ },
+ ]
+ }
+ }
+
+ audit = build_evidence_integrity_audit(
+ modules,
+ requested_symbol="600519",
+ resolved_symbol="000001",
+ trade_date="20260710",
+ )
+
+ assert audit["identity_validation"]["status"] == "conflict"
+ assert audit["basis_conflicts"]
+ assert audit["primary_conflicts"]
+ assert audit["publication_cutoff_audit"]["violations"]
+
+
+def test_no_publishable_claims_blocks_report():
+ evidence = {"symbol": "600519", "trade_date": "20260710", "modules": {}, "_meta": {}}
+
+ gate = evaluate_safety_gate(evidence, [], asset_type="company")
+
+ assert gate["decision"] == PublicationDecision.BLOCK_REPORT.value
+ assert any(issue["code"] == "NO_SUPPORTED_CLAIMS" for issue in gate["issues"])
+
+
+def test_four_audit_artifacts_separate_published_and_unpublished_claims():
+ published = _claim().to_dict()
+ unpublished = {
+ "question_id": "Q-MARKET-SHARE",
+ "question": "市场份额是否下降?",
+ "missing_evidence_effect": "blocks_claim",
+ }
+ snapshot = {
+ "snapshot_id": "sha256:fixture",
+ "evidence": {
+ "symbol": "600519",
+ "trade_date": "20260710",
+ "modules": {
+ "C2": {
+ "evidence": [
+ {
+ "evidence_id": "EV-OCF-001",
+ "metric": "operating_cash_flow",
+ "period": "2025FY",
+ "source": "annual_report",
+ }
+ ]
+ }
+ },
+ "_meta": {
+ "coverage": 50.0,
+ "available_modules": ["C2"],
+ "missing_modules": ["C4"],
+ "source_events": [],
+ },
+ },
+ }
+ opinions = {"buffett": {"unpublished_questions": [unpublished]}}
+ committee = {
+ "publishable_claims": [published],
+ "safety_gate": {"decision": "publish", "issues": []},
+ }
+
+ artifacts = build_claim_audit_artifacts(snapshot, opinions, committee)
+
+ assert set(artifacts) == {
+ "evidence_manifest",
+ "claim_ledger",
+ "coverage_report",
+ "unpublished_claims",
+ }
+ assert artifacts["claim_ledger"]["publishable_claims"] == [published]
+ assert artifacts["unpublished_claims"]["claims"] == [unpublished]
diff --git a/tests/test_research_workspace.py b/tests/test_research_workspace.py
index a6b97d5..c9557fe 100644
--- a/tests/test_research_workspace.py
+++ b/tests/test_research_workspace.py
@@ -8,13 +8,60 @@ def _pack(trade_date: str = "20260710", coverage: float = 37.5):
modules = {
f"C{index}": {
"available": index in {2, 3, 6},
- "evidence": [{"metric": f"metric_{index}", "value": index, "source": "fixture"}]
+ "evidence": [
+ {
+ "evidence_id": f"C{index}:fixture",
+ "metric": f"metric_{index}",
+ "value": index,
+ "source_type": "primary_disclosure",
+ "source": f"fixture-filing-C{index}",
+ "period": trade_date,
+ "validation_status": "accepted",
+ }
+ ]
if index in {2, 3, 6}
else [],
"gaps": [] if index in {2, 3, 6} else [f"C{index} 缺口"],
}
for index in range(1, 9)
}
+ modules["C6"]["evidence"].extend(
+ [
+ {
+ "evidence_id": "C6:price",
+ "metric": "market_quote",
+ "value": 1500.0,
+ "source_type": "primary_disclosure",
+ "source": "fixture-market-quote",
+ "period": trade_date,
+ "validation_status": "accepted",
+ },
+ {
+ "evidence_id": "C6:market-cap",
+ "metric": "total_market_cap",
+ "value": 1_800_000_000_000,
+ "source_type": "primary_disclosure",
+ "source": "fixture-market-cap",
+ "period": trade_date,
+ "validation_status": "accepted",
+ },
+ ]
+ )
+ modules["C7"] = {
+ "available": True,
+ "evidence": [
+ {
+ "evidence_id": "C7:execution",
+ "metric": "execution_cost_model_status",
+ "value": "scenario_complete",
+ "source_type": "primary_disclosure",
+ "source": "fixture-execution",
+ "period": trade_date,
+ "validation_status": "accepted",
+ }
+ ],
+ "gaps": [],
+ }
return {
"schema_version": "1.0",
"symbol": "600519",
@@ -26,8 +73,8 @@ def _pack(trade_date: str = "20260710", coverage: float = 37.5):
"modules": modules,
"_meta": {
"coverage": coverage,
- "available_modules": ["C2", "C3", "C6"],
- "missing_modules": ["C1", "C4", "C5", "C7", "C8"],
+ "available_modules": ["C2", "C3", "C6", "C7"],
+ "missing_modules": ["C1", "C4", "C5", "C8"],
"source_events": [{"source": "fixture", "status": "ok"}],
},
}
@@ -36,7 +83,7 @@ def _pack(trade_date: str = "20260710", coverage: float = 37.5):
def test_workspace_creates_recoverable_stage_artifacts(tmp_path):
manifest, workspace = build_research_workspace(_pack(), root=tmp_path)
- assert manifest["status"] == "evidence_insufficient"
+ assert manifest["status"] == "ready_for_analysis"
assert manifest["stages"]["evidence_collection"] == "complete"
assert manifest["stages"]["expert_analysis"] == "complete"
assert manifest["stages"]["committee_review"] == "complete"
@@ -50,6 +97,10 @@ def test_workspace_creates_recoverable_stage_artifacts(tmp_path):
"committee_review",
"decision_memo",
"institutional_report",
+ "evidence_manifest",
+ "claim_ledger",
+ "coverage_report",
+ "unpublished_claims",
}
assert (workspace / "workspace.json").exists()
report = (workspace / manifest["artifacts"]["institutional_report"]["path"]).read_text(encoding="utf-8")
@@ -67,7 +118,22 @@ def test_workspace_creates_recoverable_stage_artifacts(tmp_path):
assert "证据暂缺" not in report
assert "manual_review" not in report
assert "Evidence Dashboard" not in report
- assert "商业经济性" in report
+ assert "适用条件" in report
+ assert "失效条件" in report
+ assert "市场份额正在下降" not in report
+ assert "由于缺乏市场份额数据,应保持谨慎" not in report
+ assert "由于证据不足" not in report
+ assert "数据有限,因此" not in report
+ assert "仍需更多信息验证" not in report
+ unpublished = json.loads((workspace / "unpublished_claims.json").read_text(encoding="utf-8"))
+ assert any("市场份额" in item.get("question", "") for item in unpublished["claims"])
+ for filename in (
+ "evidence_manifest.json",
+ "claim_ledger.json",
+ "coverage_report.json",
+ "unpublished_claims.json",
+ ):
+ assert json.loads((workspace / filename).read_text(encoding="utf-8"))
def test_workspace_resume_preserves_manually_edited_artifact(tmp_path):
@@ -115,3 +181,36 @@ def test_research_cli_prints_report_and_workspace_path(monkeypatch, tmp_path, ca
assert len(opinions) == 6
assert {"livermore", "o_neil", "minervini"} <= set(opinions)
assert "研究问题**:短线趋势、量价突破和止损" in output
+
+
+def test_missing_price_blocks_actions_without_suppressing_supported_research(tmp_path):
+ pack = _pack()
+ pack["modules"]["C6"]["evidence"] = [
+ item for item in pack["modules"]["C6"]["evidence"]
+ if item["metric"] != "market_quote"
+ ]
+
+ manifest, workspace = build_research_workspace(pack, root=tmp_path)
+ report = (workspace / manifest["artifacts"]["institutional_report"]["path"]).read_text(
+ encoding="utf-8"
+ )
+
+ assert manifest["status"] == "action_blocked"
+ assert "## 一、执行摘要" in report
+ assert "估值或交易执行行动被阻断" in report
+ assert "建议保持谨慎" not in report
+ assert "维持观察" not in report
+
+
+def test_explicit_identity_conflict_blocks_the_research_report(tmp_path):
+ pack = _pack()
+ pack["_meta"]["identity_validation"] = {"status": "conflict"}
+
+ manifest, workspace = build_research_workspace(pack, root=tmp_path)
+ report = (workspace / manifest["artifacts"]["institutional_report"]["path"]).read_text(
+ encoding="utf-8"
+ )
+
+ assert manifest["status"] == "blocked_report"
+ assert "## 重大安全阻断" in report
+ assert "## 一、执行摘要" not in report