alphacouncil-agent 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +5 -6
  5. package/CHANGELOG.md +86 -0
  6. package/CLAUDE.md +5 -6
  7. package/README.ja.md +13 -12
  8. package/README.md +16 -16
  9. package/README.zh-CN.md +11 -11
  10. package/data/authored/core-seats.mjs +3 -3
  11. package/data/authored/quant-seats.mjs +2 -2
  12. package/data/authored/value-seats.mjs +4 -4
  13. package/data/build-profile.v1.json +1 -1
  14. package/docs/INSTALL.md +6 -10
  15. package/docs/plans/v1.1-basket-native-seats.md +267 -0
  16. package/knowledge/ai-assisted-solo/experiments/runs/a.json +10 -10
  17. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  18. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  19. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +52 -52
  20. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +68 -68
  21. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +55 -55
  22. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +71 -71
  23. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +72 -72
  24. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +51 -51
  25. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  26. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_ackman/reviews/market_capitalisation.ai-review.json +35 -35
  27. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_ackman/reviews/owner_earnings_yield.ai-review.json +15 -15
  28. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_aschenbrenner/reviews/market_capitalisation.ai-review.json +35 -35
  29. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_aschenbrenner/reviews/price_implied_cash_yield.ai-review.json +15 -15
  30. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_asness/reviews/nominal_earnings_yield_gap.ai-review.json +15 -15
  31. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_asness/reviews/real_earnings_yield_gap.ai-review.json +15 -15
  32. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_bogle/reviews/expected_return_over_long_bond.ai-review.json +15 -15
  33. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_bogle/reviews/fundamental_expected_return.ai-review.json +15 -15
  34. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_buffett/reviews/market_capitalisation.ai-review.json +35 -35
  35. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_buffett/reviews/owner_earnings_yield.ai-review.json +15 -15
  36. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_burry/reviews/net_current_asset_surplus_per_share.ai-review.json +15 -15
  37. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_burry/reviews/net_current_asset_value_per_share.ai-review.json +35 -35
  38. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_cathie_wood/reviews/current_cash_yield.ai-review.json +15 -15
  39. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_cathie_wood/reviews/market_capitalisation.ai-review.json +35 -35
  40. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_dalio/reviews/policy_real_rate.ai-review.json +15 -15
  41. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_dalio/reviews/real_curve_slope.ai-review.json +15 -15
  42. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_damodaran/reviews/implied_equity_risk_premium.ai-review.json +15 -15
  43. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_damodaran/reviews/premium_versus_long_run_average.ai-review.json +15 -15
  44. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_druckenmiller/reviews/liquidity_curve_impulse.ai-review.json +15 -15
  45. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_druckenmiller/reviews/policy_real_rate.ai-review.json +15 -15
  46. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_duan_yongping/reviews/five_year_opportunity_cost.ai-review.json +35 -35
  47. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_duan_yongping/reviews/margin_retention.ai-review.json +15 -15
  48. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_fisher/reviews/margin_retention.ai-review.json +15 -15
  49. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_fisher/reviews/reinvestment_spread.ai-review.json +15 -15
  50. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_forensic_short/reviews/cash_conversion_gap.ai-review.json +15 -15
  51. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_forensic_short/reviews/cash_cover_of_leverage.ai-review.json +15 -15
  52. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_graham/reviews/defensive_hurdle_base.ai-review.json +35 -35
  53. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_graham/reviews/half_normalised_earnings_yield.ai-review.json +15 -15
  54. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_jhunjhunwala/reviews/cash_quality_gap.ai-review.json +15 -15
  55. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_jhunjhunwala/reviews/real_structural_growth.ai-review.json +15 -15
  56. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_klarman/reviews/downside_asset_coverage.ai-review.json +15 -15
  57. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_klarman/reviews/market_capitalisation.ai-review.json +35 -35
  58. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_li_lu/reviews/cash_backed_decade_return.ai-review.json +15 -15
  59. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_li_lu/reviews/decade_return_over_long_bond.ai-review.json +15 -15
  60. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_lynch/reviews/growth_justified_capitalisation.ai-review.json +15 -15
  61. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_lynch/reviews/market_capitalisation.ai-review.json +35 -35
  62. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_marks/reviews/credit_cycle_position.ai-review.json +15 -15
  63. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_marks/reviews/equity_over_corporate_debt.ai-review.json +15 -15
  64. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_munger/reviews/debt_service_cushion.ai-review.json +15 -15
  65. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_munger/reviews/reported_earnings_cash_gap.ai-review.json +15 -15
  66. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_natenberg/reviews/normalised_skew.ai-review.json +15 -15
  67. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_natenberg/reviews/round_trip_cost.ai-review.json +15 -15
  68. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_pabrai/reviews/downside_floor_coverage.ai-review.json +15 -15
  69. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_pabrai/reviews/market_capitalisation.ai-review.json +35 -35
  70. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_simons/reviews/absolute_session_move.ai-review.json +15 -15
  71. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_simons/reviews/session_move_over_implied.ai-review.json +15 -15
  72. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_sinclair/reviews/round_trip_cost.ai-review.json +15 -15
  73. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_sinclair/reviews/volatility_per_unit_of_width.ai-review.json +15 -15
  74. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_soros/reviews/credit_cycle_position.ai-review.json +15 -15
  75. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_soros/reviews/financing_burden.ai-review.json +15 -15
  76. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_taleb/reviews/debt_service_cushion.ai-review.json +15 -15
  77. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_taleb/reviews/normalised_skew.ai-review.json +15 -15
  78. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_thorp/reviews/edge_per_unit_of_volatility.ai-review.json +15 -15
  79. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/master_thorp/reviews/net_edge.ai-review.json +15 -15
  80. package/knowledge/ai-assisted-solo/reviews/persona-v3-ai-formula-reviews/review-manifest.json +164 -164
  81. package/knowledge/solo-test/masters/master_ackman/provisional-index.json +1 -1
  82. package/knowledge/solo-test/masters/master_ackman/tools.json +6 -6
  83. package/knowledge/solo-test/masters/master_aschenbrenner/provisional-index.json +1 -1
  84. package/knowledge/solo-test/masters/master_aschenbrenner/tools.json +6 -6
  85. package/knowledge/solo-test/masters/master_asness/provisional-index.json +1 -1
  86. package/knowledge/solo-test/masters/master_bogle/provisional-index.json +1 -1
  87. package/knowledge/solo-test/masters/master_buffett/provisional-index.json +1 -1
  88. package/knowledge/solo-test/masters/master_buffett/tools.json +6 -6
  89. package/knowledge/solo-test/masters/master_burry/provisional-index.json +1 -1
  90. package/knowledge/solo-test/masters/master_burry/tools.json +6 -6
  91. package/knowledge/solo-test/masters/master_cathie_wood/provisional-index.json +1 -1
  92. package/knowledge/solo-test/masters/master_cathie_wood/tools.json +6 -6
  93. package/knowledge/solo-test/masters/master_dalio/provisional-index.json +1 -1
  94. package/knowledge/solo-test/masters/master_damodaran/provisional-index.json +1 -1
  95. package/knowledge/solo-test/masters/master_druckenmiller/provisional-index.json +1 -1
  96. package/knowledge/solo-test/masters/master_duan_yongping/provisional-index.json +1 -1
  97. package/knowledge/solo-test/masters/master_duan_yongping/tools.json +6 -6
  98. package/knowledge/solo-test/masters/master_fisher/provisional-index.json +1 -1
  99. package/knowledge/solo-test/masters/master_forensic_short/provisional-index.json +1 -1
  100. package/knowledge/solo-test/masters/master_graham/decision_policy.json +1 -1
  101. package/knowledge/solo-test/masters/master_graham/provisional-index.json +1 -1
  102. package/knowledge/solo-test/masters/master_graham/tools.json +6 -6
  103. package/knowledge/solo-test/masters/master_jhunjhunwala/provisional-index.json +1 -1
  104. package/knowledge/solo-test/masters/master_klarman/decision_policy.json +1 -1
  105. package/knowledge/solo-test/masters/master_klarman/provisional-index.json +1 -1
  106. package/knowledge/solo-test/masters/master_klarman/tools.json +6 -6
  107. package/knowledge/solo-test/masters/master_li_lu/provisional-index.json +1 -1
  108. package/knowledge/solo-test/masters/master_lynch/provisional-index.json +1 -1
  109. package/knowledge/solo-test/masters/master_lynch/tools.json +6 -6
  110. package/knowledge/solo-test/masters/master_marks/provisional-index.json +1 -1
  111. package/knowledge/solo-test/masters/master_munger/provisional-index.json +1 -1
  112. package/knowledge/solo-test/masters/master_natenberg/provisional-index.json +1 -1
  113. package/knowledge/solo-test/masters/master_pabrai/decision_policy.json +1 -1
  114. package/knowledge/solo-test/masters/master_pabrai/provisional-index.json +1 -1
  115. package/knowledge/solo-test/masters/master_pabrai/tools.json +6 -6
  116. package/knowledge/solo-test/masters/master_simons/provisional-index.json +1 -1
  117. package/knowledge/solo-test/masters/master_sinclair/provisional-index.json +1 -1
  118. package/knowledge/solo-test/masters/master_soros/provisional-index.json +1 -1
  119. package/knowledge/solo-test/masters/master_taleb/provisional-index.json +1 -1
  120. package/knowledge/solo-test/masters/master_thorp/provisional-index.json +1 -1
  121. package/knowledge/solo-test/persona-v3-solo-test-formulas/compilation-manifest.json +21 -21
  122. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_ackman/components/tools.json +6 -6
  123. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_ackman/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  124. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_aschenbrenner/components/tools.json +6 -6
  125. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_aschenbrenner/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  126. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_buffett/components/tools.json +6 -6
  127. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_buffett/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  128. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_burry/components/tools.json +6 -6
  129. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_burry/provisional-derivations/net_current_asset_value_per_share.derived-proxy-evidence.json +3 -3
  130. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_cathie_wood/components/tools.json +6 -6
  131. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_cathie_wood/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  132. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_duan_yongping/components/tools.json +6 -6
  133. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_duan_yongping/provisional-derivations/five_year_opportunity_cost.derived-proxy-evidence.json +3 -3
  134. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_graham/components/tools.json +6 -6
  135. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_graham/provisional-derivations/defensive_hurdle_base.derived-proxy-evidence.json +3 -3
  136. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_klarman/components/tools.json +6 -6
  137. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_klarman/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  138. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_lynch/components/tools.json +6 -6
  139. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_lynch/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  140. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_pabrai/components/tools.json +6 -6
  141. package/knowledge/solo-test/persona-v3-solo-test-formulas/master_pabrai/provisional-derivations/market_capitalisation.derived-proxy-evidence.json +3 -3
  142. package/mcp/lib/basket-news.mjs +181 -0
  143. package/mcp/lib/breadth.mjs +29 -1
  144. package/mcp/lib/cross-market.mjs +180 -0
  145. package/mcp/lib/fund-observations.mjs +152 -0
  146. package/mcp/lib/fundamentals.mjs +6 -1
  147. package/mcp/lib/funds.mjs +35 -0
  148. package/mcp/lib/grounding.mjs +55 -2
  149. package/mcp/lib/index-aggregate.mjs +16 -1
  150. package/mcp/lib/industry.mjs +23 -2
  151. package/mcp/lib/instrument-facts.mjs +318 -17
  152. package/mcp/lib/instruments.mjs +25 -0
  153. package/mcp/lib/markdown.mjs +41 -8
  154. package/mcp/lib/personas-v3/grounding-adapter.mjs +139 -0
  155. package/mcp/lib/xbrl-series.mjs +4 -2
  156. package/package.json +2 -2
  157. package/scripts/lib/persona-v3-solo-formula-pipeline.mjs +18 -1
@@ -13,12 +13,22 @@
13
13
  */
14
14
 
15
15
  import { LIMITS } from "./constants.mjs";
16
- import { fetchFundHoldings, fetchFundMetadata, lookThroughAggregate, topHoldingsCoverage } from "./funds.mjs";
16
+ import {
17
+ MIN_LOOK_THROUGH_COVERAGE,
18
+ fetchFundHoldings,
19
+ fetchFundMetadata,
20
+ lookThroughAggregate,
21
+ topHoldingsCoverage,
22
+ } from "./funds.mjs";
17
23
  import { fetchIndexAggregate, INDEX_PROXIES, normalizeIndexSymbol } from "./index-aggregate.mjs";
18
24
  import { fetchImpliedErp } from "./damodaran.mjs";
19
- import { fetchBasketBreadth } from "./breadth.mjs";
20
- import { fetchFundamentals } from "./fundamentals.mjs";
21
- import { fetchUniverse } from "./sec.mjs";
25
+ import { chartUrl, fetchBasketBreadth, parseDailyCloses } from "./breadth.mjs";
26
+ import { flowInputs, recordFundObservation } from "./fund-observations.mjs";
27
+ import { fundFlow } from "./funds.mjs";
28
+ import { fetchText } from "./quotes.mjs";
29
+ import { deriveFundamentals } from "./fundamentals.mjs";
30
+ import { evaluateRules } from "./screen.mjs";
31
+ import { fetchCompanyFacts, fetchUniverse } from "./sec.mjs";
22
32
 
23
33
  /**
24
34
  * How many constituents may be resolved for a look-through pass.
@@ -51,6 +61,15 @@ export const LOOK_THROUGH_TARGET_COVERAGE = 0.6;
51
61
  export const LOOK_THROUGH_FACT_RULES = Object.freeze({
52
62
  "financial.leverage": "leverage.debt_to_equity",
53
63
  "valuation.revenue_growth": "growth.revenue_growth",
64
+ // Every one of these is a pure ratio that a weighted mean of constituents answers honestly,
65
+ // and every one of them was already legal to aggregate -- it simply had no line here, which
66
+ // is why eighteen seats stayed silent on a basket whose data was complete.
67
+ "accounting.cash_conversion": "accounting.cash_conversion",
68
+ "financial.gross_margin_5y": "profitability.gross_margin",
69
+ "financial.net_margin_5y": "profitability.net_margin",
70
+ "financial.return_on_equity_10y": "profitability.return_on_equity_10y",
71
+ "financial.incremental_return_on_capital": "profitability.incremental_return_on_capital",
72
+ "financial.interest_coverage": "coverage.interest_coverage",
54
73
  });
55
74
 
56
75
  /**
@@ -63,6 +82,29 @@ export const LOOK_THROUGH_FACT_RULES = Object.freeze({
63
82
  * is real and worth doing; it is simply not this pass, and pretending otherwise by summing is
64
83
  * the failure mode, not the shortcut.
65
84
  */
85
+ /**
86
+ * Absolute company figures a fund owns a real, computable dollar share of.
87
+ *
88
+ * `LOOK_THROUGH_BLOCKED` refuses the naive version of this and is right to: summing every
89
+ * constituent's owner earnings produces "the ETF's owner earnings" as though the fund owned
90
+ * each business outright, which it does not. What the fund does own is a stake — its position
91
+ * value over the company's market capitalisation — and that stake's claim on the figure is
92
+ * ordinary look-through accounting:
93
+ *
94
+ * fund's claim = AUM x SUM over i of ( weight_i / market_cap_i ) x figure_i
95
+ *
96
+ * The result is in dollars and is the fund's, not the index's. Because the fund's own market
97
+ * capitalisation is its AUM, every seat that divides one of these by market capitalisation
98
+ * gets the weighted look-through yield without a single change to its method.
99
+ */
100
+ export const LOOK_THROUGH_CLAIM_FACTS = Object.freeze([
101
+ "financial.owner_earnings",
102
+ "financial.free_cash_flow_5y",
103
+ "financial.net_current_asset_value",
104
+ "valuation.downside_asset_value",
105
+ "valuation.downside_floor",
106
+ ]);
107
+
66
108
  export const LOOK_THROUGH_BLOCKED = Object.freeze({
67
109
  "financial.owner_earnings": "absolute currency; needs a per-constituent market capitalisation to become a yield",
68
110
  "financial.net_current_asset_value": "absolute currency; needs a per-constituent market capitalisation to become a ratio",
@@ -71,6 +113,35 @@ export const LOOK_THROUGH_BLOCKED = Object.freeze({
71
113
  "capital_allocation.share_count": "a count, not a rate; it has no portfolio-level meaning",
72
114
  });
73
115
 
116
+ /**
117
+ * Mechanical-screen rule id -> the pack fact id it supplies, mirroring the grounding adapter's
118
+ * own map. Percent rules publish 15 for 15%, and every pack ratio is a decimal.
119
+ */
120
+ const SCREEN_RULE_FACTS = Object.freeze({
121
+ roe_10y: "financial.return_on_equity_10y",
122
+ interest_cover: "financial.interest_coverage",
123
+ gross_margin: "financial.gross_margin_5y",
124
+ ocf_over_ni: "accounting.cash_conversion",
125
+ net_margin: "financial.net_margin_5y",
126
+ // An absolute, and the only one the screen carries. It is not aggregated as a ratio -- it
127
+ // feeds the ownership-claim pass, where dollars are the right unit.
128
+ fcf_5y: "financial.free_cash_flow_5y",
129
+ });
130
+ const SCREEN_PERCENT_RULES = new Set(["roe_10y", "gross_margin", "net_margin"]);
131
+
132
+ /**
133
+ * The unit a look-through fact must carry.
134
+ *
135
+ * The aggregate of a quantity IS that quantity, so it has to arrive under the same contract the
136
+ * single-company path declares. Emitting every aggregate as a bare `decimal` made the executor
137
+ * reject coverage and cash conversion, which are multiples -- the fact was present and unusable,
138
+ * which reads exactly like the fact being absent.
139
+ */
140
+ const LOOK_THROUGH_UNITS = Object.freeze({
141
+ "financial.interest_coverage": "multiple",
142
+ "accounting.cash_conversion": "multiple",
143
+ });
144
+
74
145
  export const LOOK_THROUGH_FACT_IDS = Object.freeze(Object.keys(LOOK_THROUGH_FACT_RULES));
75
146
 
76
147
  const finite = (value) => typeof value === "number" && Number.isFinite(value);
@@ -241,11 +312,15 @@ export async function resolveConstituentFacts(holdings, { signal, asOf = null, c
241
312
  // The aggregate inherits whatever span its inputs covered; without carrying it the basket
242
313
  // fact arrives as a bare number that no duration contract can accept.
243
314
  const perHoldingPeriods = new Map();
315
+ // A share count is not aggregable and is not aggregated. It is kept because a market
316
+ // capitalisation -- shares times price -- is what turns an absolute figure into the fund's
317
+ // own dollar claim on it.
318
+ const shareCounts = new Map();
244
319
  const unavailable = [];
245
320
  const ranked = [...holdings]
246
321
  .sort((left, right) => (right.weight || 0) - (left.weight || 0))
247
322
  .slice(0, LOOK_THROUGH_MAX_CONSTITUENTS);
248
- if (!ranked.length) return { perHolding, perHoldingPeriods, unavailable, attempted: 0 };
323
+ if (!ranked.length) return { perHolding, perHoldingPeriods, shareCounts, unavailable, attempted: 0 };
249
324
 
250
325
  let universe = null;
251
326
  try {
@@ -255,6 +330,7 @@ export async function resolveConstituentFacts(holdings, { signal, asOf = null, c
255
330
  return {
256
331
  perHolding,
257
332
  perHoldingPeriods,
333
+ shareCounts,
258
334
  unavailable: [`look-through: SEC ticker universe unavailable (${String(error?.message || error)})`],
259
335
  attempted: 0,
260
336
  };
@@ -267,12 +343,29 @@ export async function resolveConstituentFacts(holdings, { signal, asOf = null, c
267
343
  const cik = universe.get(ticker);
268
344
  if (!cik) { unavailable.push(`look-through ${ticker}: no SEC registrant for this ticker`); continue; }
269
345
  try {
270
- const derived = await fetchFundamentals({ cik, ticker, asOf, signal });
346
+ // One document, both readers. The derived fundamentals and the mechanical screen read
347
+ // the same Company Facts filing, and fetching it twice per constituent would double
348
+ // the cost of a look-through for metrics that were already on disk. The screen is
349
+ // where the margins, cash conversion, ten-year ROE and interest coverage live -- which
350
+ // is why wiring only the fundamentals path left those aggregates permanently empty.
351
+ const companyFacts = await fetchCompanyFacts(cik, { signal });
352
+ const derived = deriveFundamentals({ companyFacts, asOf });
353
+ const screened = evaluateRules(companyFacts, { asOf });
271
354
  const facts = {};
272
355
  const periods = {};
356
+ for (const rule of screened.rules || []) {
357
+ const factId = SCREEN_RULE_FACTS[rule?.id];
358
+ if (!factId || rule.skipped || !Number.isFinite(rule.value)) continue;
359
+ // The screen publishes percentages as 15 for 15%; every pack ratio is a decimal.
360
+ facts[factId] = SCREEN_PERCENT_RULES.has(rule.id) ? rule.value / 100 : rule.value;
361
+ if (rule.period_start && rule.period_end) {
362
+ periods[factId] = { start: rule.period_start, end: rule.period_end };
363
+ }
364
+ }
273
365
  for (const [factId, metric] of Object.entries(derived.metrics || {})) {
274
366
  if (!metric || !Number.isFinite(metric.value)) continue;
275
367
  facts[factId] = metric.value;
368
+ if (factId === "capital_allocation.share_count") shareCounts.set(ticker, metric.value);
276
369
  if (metric.period_start && metric.period_end) {
277
370
  periods[factId] = { start: metric.period_start, end: metric.period_end };
278
371
  }
@@ -285,7 +378,7 @@ export async function resolveConstituentFacts(holdings, { signal, asOf = null, c
285
378
  }
286
379
  };
287
380
  await Promise.all(Array.from({ length: Math.min(concurrency, ranked.length) }, worker));
288
- return { perHolding, perHoldingPeriods, unavailable, attempted: ranked.length };
381
+ return { perHolding, perHoldingPeriods, shareCounts, unavailable, attempted: ranked.length };
289
382
  }
290
383
 
291
384
  /**
@@ -297,20 +390,134 @@ export async function resolveConstituentFacts(holdings, { signal, asOf = null, c
297
390
  * correct outcome and not a failure.
298
391
  */
299
392
  /**
300
- * The interval a basket-level aggregate covers: the widest span its contributing constituents
301
- * reported. Stating a narrower one would claim coverage the inputs do not have, and stating
302
- * none at all leaves the fact unusable by any duration contract.
393
+ * The interval a basket-level aggregate covers.
394
+ *
395
+ * The outer envelope -- earliest start to latest end -- is the wrong answer. Constituents keep
396
+ * different fiscal calendars, so the union of forty five-year spans is five years plus a year
397
+ * of calendar edges, and a fact that genuinely covers five years then fails a five-year
398
+ * contract for a reason that has nothing to do with its coverage.
399
+ *
400
+ * The median end date and the median span describe what the aggregate actually covers: a
401
+ * typical constituent's reporting window, which is the window the number was averaged over.
303
402
  */
304
403
  function aggregatePeriod(perHoldingPeriods, factId, tickers) {
305
- let start = null;
306
- let end = null;
404
+ const spans = [];
307
405
  for (const ticker of tickers) {
308
406
  const span = perHoldingPeriods?.get(ticker)?.[factId];
309
407
  if (!span?.start || !span?.end) continue;
310
- if (!start || span.start < start) start = span.start;
311
- if (!end || span.end > end) end = span.end;
408
+ const start = Date.parse(span.start);
409
+ const end = Date.parse(span.end);
410
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
411
+ spans.push({ end, days: (end - start) / 86_400_000 });
412
+ }
413
+ if (!spans.length) return {};
414
+ const median = (values) => {
415
+ const sorted = [...values].sort((left, right) => left - right);
416
+ return sorted[Math.floor(sorted.length / 2)];
417
+ };
418
+ const end = median(spans.map((span) => span.end));
419
+ const days = median(spans.map((span) => span.days));
420
+ return {
421
+ period_start: new Date(end - days * 86_400_000).toISOString().slice(0, 10),
422
+ period_end: new Date(end).toISOString().slice(0, 10),
423
+ };
424
+ }
425
+
426
+ /**
427
+ * The dollar claim a fund has on an absolute company figure, summed across constituents.
428
+ *
429
+ * Requires a market capitalisation per constituent, which is its own filed share count times
430
+ * its last close — both already in hand from the fundamentals pass and the breadth pass. A
431
+ * constituent missing either is excluded and costs coverage rather than being assumed.
432
+ */
433
+ /**
434
+ * A share count for a fund whose holdings file does not carry one.
435
+ *
436
+ * There are two routes and they are not equally good, which matters because a flow is a
437
+ * DIFFERENCE of two counts and therefore keeps only their error.
438
+ *
439
+ * The good route is the issuer's own assets over its own net asset value, on the same date
440
+ * from the same source: that ratio IS shares outstanding, an identity rather than an estimate,
441
+ * and it is trustworthy enough to difference.
442
+ *
443
+ * The weak route is the assets implied by disclosed positions over the fund's market price. It
444
+ * is fine for a market capitalisation -- the product is the size we already had -- and unfit
445
+ * for a flow, because a 95%-priced position sum and a premium to NAV each carry error that a
446
+ * subtraction leaves behind while cancelling the number. `flowInputs` refuses it by basis.
447
+ */
448
+ async function impliedFundShares(symbol, netAssets, metadata, signal) {
449
+ if (finite(metadata?.nav) && metadata.nav > 0 && finite(metadata?.aum) && metadata.aum > 0) {
450
+ return { value: metadata.aum / metadata.nav, how: "issuer_aum_over_nav" };
451
+ }
452
+ if (!finite(netAssets) || netAssets <= 0) return null;
453
+ try {
454
+ const closes = parseDailyCloses(JSON.parse(await fetchText(chartUrl(symbol), LIMITS.QUOTE_FETCH_MS, signal)));
455
+ const close = closes?.at(-1);
456
+ if (!finite(close) || close <= 0) return null;
457
+ return { value: netAssets / close, how: "assets_from_disclosed_positions_over_market_price" };
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ export function lookThroughClaims({ holdings, perHoldingFacts, perHoldingPeriods, shareCounts, closes, netAssets, holdingsMeta }) {
464
+ const facts = [];
465
+ const unavailable = [];
466
+ if (!finite(netAssets) || netAssets <= 0) {
467
+ unavailable.push("look-through claims: the fund's own size is unknown, so a dollar claim cannot be scaled");
468
+ return { facts, unavailable };
469
+ }
470
+ const priced = new Map();
471
+ for (const holding of holdings || []) {
472
+ const ticker = String(holding?.ticker || "").toUpperCase();
473
+ const shares = shareCounts?.get?.(ticker);
474
+ const close = closes?.[ticker];
475
+ if (!finite(holding?.weight) || !finite(shares) || shares <= 0 || !finite(close) || close <= 0) continue;
476
+ priced.set(ticker, { weight: holding.weight, marketCap: shares * close });
477
+ }
478
+ if (!priced.size) {
479
+ unavailable.push("look-through claims: no constituent supplied both a share count and a price");
480
+ return { facts, unavailable };
481
+ }
482
+ for (const factId of LOOK_THROUGH_CLAIM_FACTS) {
483
+ let claim = 0;
484
+ let covered = 0;
485
+ for (const [ticker, { weight, marketCap }] of priced) {
486
+ const value = perHoldingFacts?.get?.(ticker)?.[factId];
487
+ if (!finite(value)) continue;
488
+ claim += (weight / marketCap) * value;
489
+ covered += weight;
490
+ }
491
+ if (covered < MIN_LOOK_THROUGH_COVERAGE) {
492
+ unavailable.push(
493
+ `look-through claim ${factId}: ${(covered * 100).toFixed(1)}% of the basket by weight supplied it,`
494
+ + ` below the ${(MIN_LOOK_THROUGH_COVERAGE * 100).toFixed(0)}% floor`,
495
+ );
496
+ continue;
497
+ }
498
+ facts.push({
499
+ fact_id: factId,
500
+ value: Number((claim * netAssets).toFixed(2)),
501
+ // A claim on a one-year figure covers a year and a claim on a five-year total covers
502
+ // five. It inherits the window its inputs reported, for the same reason the ratio
503
+ // aggregates do.
504
+ ...aggregatePeriod(perHoldingPeriods, factId, [...priced.keys()]),
505
+ value_kind: "monetary",
506
+ unit: "currency_units",
507
+ currency: "USD",
508
+ scale: 1,
509
+ source_kind: "issuer_disclosure",
510
+ source_url: holdingsMeta?.source_url,
511
+ public_at: holdingsMeta?.public_at,
512
+ observation_date: holdingsMeta?.as_of,
513
+ confidence: 0.65,
514
+ derivation: "rederived",
515
+ method: "fund_ownership_share_of_constituent_figure",
516
+ coverage_weight: Number(covered.toFixed(6)),
517
+ title: `${holdingsMeta?.symbol || "fund"} look-through claim on ${factId}`,
518
+ });
312
519
  }
313
- return start && end ? { period_start: start, period_end: end } : {};
520
+ return { facts, unavailable };
314
521
  }
315
522
 
316
523
  export function lookThroughFacts({ holdings, perHoldingFacts, perHoldingPeriods, factIds, holdingsMeta }) {
@@ -342,7 +549,7 @@ export function lookThroughFacts({ holdings, perHoldingFacts, perHoldingPeriods,
342
549
  fact_id: factId,
343
550
  value: aggregate.value,
344
551
  value_kind: "ratio",
345
- unit: "decimal",
552
+ unit: LOOK_THROUGH_UNITS[factId] || "decimal",
346
553
  ratio_denominator: `weight_${aggregate.method}`,
347
554
  source_kind: "issuer_disclosure",
348
555
  source_url: holdingsMeta?.source_url,
@@ -456,12 +663,14 @@ export async function gatherInstrumentFacts({
456
663
  }
457
664
  unavailable.push(...(erp?.unavailable || []));
458
665
  unavailable.push(...(aggregate?.unavailable || []));
666
+ let breadthResult = null;
459
667
  if (holdings?.holdings?.length) {
460
668
  facts.push(...structureFacts(holdings, metadata));
461
669
  // Breadth is computed from the basket rather than bought: every free screener that
462
670
  // publishes it forbids the query, and the holdings are already in hand.
463
- const breadth = await fetchBasketBreadth(holdings.holdings, { signal })
671
+ breadthResult = await fetchBasketBreadth(holdings.holdings, { signal })
464
672
  .catch((error) => ({ available: false, unavailable: [`breadth: ${String(error?.message || error)}`] }));
673
+ const breadth = breadthResult;
465
674
  unavailable.push(...(breadth.unavailable || []));
466
675
  if (breadth.available) {
467
676
  const shared = {
@@ -497,6 +706,77 @@ export async function gatherInstrumentFacts({
497
706
  title: `${holdings.symbol} assets from disclosed positions`,
498
707
  });
499
708
  }
709
+ // The fund's own shares outstanding. This is the fund as an issuer, not an aggregate of
710
+ // anything -- and it is what lets a seat build the fund's market capitalisation with the
711
+ // same arithmetic it uses on a company, which is why ten seats could not read a basket.
712
+ const fundShares = finite(holdings.shares_outstanding)
713
+ ? { value: holdings.shares_outstanding, how: "issuer_disclosed_shares_outstanding" }
714
+ : await impliedFundShares(holdings.symbol, breadth.net_assets, metadata, signal);
715
+ if (fundShares) {
716
+ facts.push({
717
+ ...shared,
718
+ fact_id: "capital_allocation.share_count",
719
+ value: Number(fundShares.value.toFixed(2)),
720
+ value_kind: "count",
721
+ unit: "shares",
722
+ ratio_denominator: undefined,
723
+ derivation: fundShares.how === "issuer_disclosed_shares_outstanding" ? "reported" : "rederived",
724
+ method: fundShares.how,
725
+ title: `${holdings.symbol} shares outstanding`,
726
+ });
727
+ }
728
+ // What this run saw about the fund's size, appended to its own ledger. No issuer serves
729
+ // a share-count history keylessly, so the history is built one run at a time and a flow
730
+ // becomes computable from the second observation onward.
731
+ const observed = recordFundObservation({
732
+ symbol: holdings.symbol,
733
+ asOf: holdings.as_of,
734
+ sharesOutstanding: fundShares?.value ?? null,
735
+ sharesBasis: fundShares?.how ?? null,
736
+ nav: finite(metadata?.nav) ? metadata.nav : null,
737
+ netAssets: finite(metadata?.aum) ? metadata.aum : breadth.net_assets,
738
+ sourceUrl: holdings.source_url,
739
+ });
740
+ const inputs = flowInputs(observed.observations);
741
+ const flow = inputs
742
+ ? fundFlow({ sharesNow: inputs.sharesNow, sharesPrior: inputs.sharesPrior, nav: inputs.nav, issuer: holdings.issuer })
743
+ : null;
744
+ if (flow && finite(flow.value)) {
745
+ facts.push({
746
+ ...shared,
747
+ fact_id: "fund.net_flow",
748
+ value: flow.value,
749
+ value_kind: "monetary",
750
+ unit: "currency_units",
751
+ currency: "USD",
752
+ scale: 1,
753
+ ratio_denominator: undefined,
754
+ derivation: "rederived",
755
+ observation_date: inputs.asOf,
756
+ method: `${flow.construction.formula}; ${inputs.priorAsOf} to ${inputs.asOf}, ${inputs.gapDays} days apart`,
757
+ title: `${holdings.symbol} net creation over ${inputs.gapDays} days`,
758
+ });
759
+ if (finite(inputs.netAssets) && inputs.netAssets > 0) {
760
+ facts.push({
761
+ ...shared,
762
+ fact_id: "fund.net_flow_ratio",
763
+ value: Number((flow.value / inputs.netAssets).toFixed(6)),
764
+ ratio_denominator: "net_assets",
765
+ derivation: "rederived",
766
+ observation_date: inputs.asOf,
767
+ method: `net creation over assets; ${inputs.priorAsOf} to ${inputs.asOf}, ${inputs.gapDays} days apart`,
768
+ title: `${holdings.symbol} net creation as a share of assets`,
769
+ });
770
+ }
771
+ } else {
772
+ const filed = observed.observations.filter((row) => row.shares_basis === "issuer_disclosed_shares_outstanding").length;
773
+ unavailable.push(filed >= 1
774
+ ? `fund flow for ${holdings.symbol}: ${filed} issuer-published share-count observation(s) on record;`
775
+ + " a flow needs two, and no issuer serves a keyless history. The ledger grows one run at a time."
776
+ : `fund flow for ${holdings.symbol}: ${holdings.issuer} publishes no shares outstanding, and the count`
777
+ + " reconstructed from assets over price may size the fund but must not be differenced into a flow --"
778
+ + " the subtraction cancels the number and keeps the error.");
779
+ }
500
780
  // The gap between weighted and counted breadth IS the concentration story: a
501
781
  // cap-weighted basket can be above its average on weight while most members are below.
502
782
  facts.push({
@@ -516,10 +796,12 @@ export async function gatherInstrumentFacts({
516
796
  if (holdings?.holdings?.length && lookThroughFactIds.length) {
517
797
  let resolved = perHoldingFacts;
518
798
  let resolvedPeriods = null;
799
+ let resolvedShareCounts = null;
519
800
  if (!resolved) {
520
801
  const constituents = await resolveConstituentFacts(holdings.holdings, { signal, asOf });
521
802
  resolved = constituents.perHolding;
522
803
  resolvedPeriods = constituents.perHoldingPeriods;
804
+ resolvedShareCounts = constituents.shareCounts;
523
805
  unavailable.push(...constituents.unavailable);
524
806
  provenance.look_through = {
525
807
  constituents_attempted: constituents.attempted,
@@ -535,12 +817,31 @@ export async function gatherInstrumentFacts({
535
817
  });
536
818
  facts.push(...through.facts);
537
819
  unavailable.push(...through.unavailable);
820
+
821
+ // The fund's own size is what scales a per-dollar claim back into dollars. Prefer the
822
+ // issuer's published figure and fall back to the assets implied by the disclosed
823
+ // positions, which is the same number the size fact above is built from.
824
+ const netAssets = finite(metadata?.aum) ? metadata.aum : breadthResult?.net_assets;
825
+ const claims = lookThroughClaims({
826
+ holdings: holdings.holdings,
827
+ perHoldingFacts: resolved,
828
+ perHoldingPeriods: resolvedPeriods,
829
+ shareCounts: resolvedShareCounts,
830
+ closes: breadthResult?.closes,
831
+ netAssets,
832
+ holdingsMeta: holdings,
833
+ });
834
+ facts.push(...claims.facts);
835
+ unavailable.push(...claims.unavailable);
538
836
  }
539
837
 
540
838
  return {
541
839
  symbol,
542
840
  research_model: instrument?.research_model || "market_instrument",
543
841
  provenance,
842
+ // The disclosed positions themselves, so a caller that needs the basket's identity rather
843
+ // than its aggregates -- news, for one -- does not fetch the holdings file a second time.
844
+ holdings: holdings?.holdings || null,
544
845
  facts,
545
846
  // Several facts have more than one route -- the implied ERP and the valuation percentile
546
847
  // are computed from the published workbook when the index feed cannot supply them -- and
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { localized } from "./lang.mjs";
12
+ import { FUND_REGISTRY } from "./funds.mjs";
12
13
 
13
14
  export const ASSET_TYPES = Object.freeze([
14
15
  "equity",
@@ -35,10 +36,24 @@ function normalizedRawType(value) {
35
36
  return String(value || "").trim().toUpperCase().replace(/[\s-]+/gu, "_");
36
37
  }
37
38
 
39
+ /**
40
+ * Cash indices people type without the caret.
41
+ *
42
+ * `^SOX` classified as an index and `SOX` did not, so a run on the name everyone actually uses
43
+ * produced no basket facts at all and every seat abstained for want of data it was never going
44
+ * to get. These are index names, not listed tickers -- deliberately a short curated set rather
45
+ * than the whole alias table, because an alias that collides with a real ticker would route a
46
+ * tradable security into the index path, which is the mirror image of the same defect.
47
+ */
48
+ const BARE_INDEX_SYMBOLS = new Set([
49
+ "SOX", "SPX", "NDX", "DJIA", "DJI", "RUT", "VIX", "IXIC", "GSPC", "COMPQ", "NYA", "OEX", "XAX",
50
+ ]);
51
+
38
52
  function symbolHeuristic(symbol) {
39
53
  const text = String(symbol || "").trim().toUpperCase();
40
54
  if (!text) return null;
41
55
  if (text.startsWith("^")) return "index";
56
+ if (BARE_INDEX_SYMBOLS.has(text)) return "index";
42
57
  if (text.endsWith("=F")) return "future";
43
58
  if (text.endsWith("=X")) return "fx";
44
59
  if (/^[A-Z0-9]+-USD$/u.test(text)) return "crypto";
@@ -94,6 +109,16 @@ export function classifyInstrument({ symbol, quote, filer } = {}) {
94
109
  classificationSource = "sec_filer_sic";
95
110
  }
96
111
  }
112
+ // A basket we hold an issuer holdings endpoint for is a basket, whatever a quote feed did or
113
+ // did not say. This is a lookup of something this repository states, not a guess from ticker
114
+ // shape, and it runs only after real evidence has had its turn so genuine metadata still
115
+ // wins and still reports its own provenance. It strengthens the fail-closed rule below
116
+ // rather than weakening it: the outcome is a fund, which is the one thing that can never be
117
+ // sent through an operating-company screen.
118
+ if (!assetType && Object.hasOwn(FUND_REGISTRY, String(symbol || quote?.symbol || "").trim().toUpperCase())) {
119
+ assetType = "etf";
120
+ classificationSource = "registered_fund_holdings_source";
121
+ }
97
122
  // Deliberately fail closed. A bare ticker shape tells us nothing: SPY, QQQ and VOO all
98
123
  // match it, and the previous `equity` fallback sent them into an operating-company Company
99
124
  // Facts screen whenever Yahoo metadata was missing -- the exact QQQ defect 0.9.5 set out to
@@ -118,25 +118,33 @@ const MASTER_STATEMENT_COPY = Object.freeze({
118
118
  heading: "\u9010\u5e2d\u65b9\u6cd5\u8f93\u51fa", acted: "\u6709\u5224\u65ad\u7684\u5e2d\u4f4d", abstained: "\u8bf4\u8fd9\u4e0d\u5f52\u5b83\u7ba1\u7684\u5e2d\u4f4d",
119
119
  stance: "\u7acb\u573a", intent: "\u610f\u5411", origin: "\u9648\u8bcd\u6765\u6e90", statement: "\u672c\u8f6e\u53d1\u8a00\uff08\u4e0d\u662f\u672c\u4eba\u5f15\u8bed\uff09",
120
120
  findings: "\u5173\u952e\u53d1\u73b0", disagreements: "\u4e0e\u5206\u6790\u5e08\u5206\u6b67", change: "\u6539\u53d8\u5224\u65ad\u6761\u4ef6", sources: "\u6765\u6e90\u6216\u660e\u786e\u7f3a\u53e3",
121
- abstainLead: (n) => `\u53e6\u6709 ${n} \u5e2d\u5728\u672c\u8f6e\u4e0d\u7ed9\u65b9\u5411\uff0c\u5404\u81ea\u7f3a\u7684\u662f\u65b9\u6cd5\u5fc5\u9700\u7684\u8f93\u5165\uff0c\u8fd9\u4e0d\u662f\u770b\u7a7a\u7968\uff1a`,
121
+ abstainLead: (n) => `\u53e6\u6709 ${n} \u5e2d\u672c\u8f6e\u672a\u80fd\u53d6\u5f97\u5176\u65b9\u6cd5\u5fc5\u9700\u7684\u8f93\u5165\uff0c\u56e0\u6b64\u6ca1\u6709\u7ed9\u51fa\u65b9\u5411\u3002\u8fd9\u662f\u6570\u636e\u7f3a\u53e3\uff0c\u4e0d\u662f\u770b\u7a7a\u7968\uff1a`,
122
+ declined: "\u770b\u8fc7\u4e4b\u540e\u51b3\u5b9a\u4e0d\u53c2\u4e0e\u7684\u5e2d\u4f4d",
123
+ declinedLead: (n) => `\u4ee5\u4e0b ${n} \u5e2d\u7684\u65b9\u6cd5\u8dd1\u5b8c\u4e86\uff0c\u5e76\u4e14\u5f97\u51fa\u4e86\u201c\u4e0d\u662f\u8fd9\u4e2a\u201d\u3002\u8fd9\u662f\u5224\u65ad\uff0c\u4e0d\u662f\u7f3a\u6570\u636e\uff1a`,
122
124
  },
123
125
  en: {
124
126
  heading: "Method-Seat Outputs", acted: "Seats with a view", abstained: "Seats that say this is not theirs to call",
125
127
  stance: "Stance", intent: "Intent", origin: "Statement source", statement: "Recorded statement (not a quote)",
126
128
  findings: "Key findings", disagreements: "Disagreements", change: "What would change the view", sources: "Sources or explicit gaps",
127
- abstainLead: (n) => `A further ${n} seat(s) issue no direction this round, each missing a method-critical input. These are not bearish votes:`,
129
+ abstainLead: (n) => `A further ${n} seat(s) issue no direction because a method-critical input did not arrive this round. This is a data gap, not a bearish vote:`,
130
+ declined: "Seats whose method examined this and declined",
131
+ declinedLead: (n) => `${n} seat(s) ran their method to completion and it returned "not this one". These are judgments, not missing data:`,
128
132
  },
129
133
  ja: {
130
134
  heading: "\u30e1\u30bd\u30c3\u30c9\u5e2d\u3054\u3068\u306e\u51fa\u529b", acted: "\u5224\u65ad\u3092\u793a\u3057\u305f\u5e2d", abstained: "\u81ea\u5206\u306e\u62c5\u5f53\u3067\u306f\u306a\u3044\u3068\u3057\u305f\u5e2d",
131
135
  stance: "\u30b9\u30bf\u30f3\u30b9", intent: "\u610f\u5411", origin: "\u898b\u89e3\u306e\u751f\u6210\u5143", statement: "\u4eca\u56de\u306e\u767a\u8a00\uff08\u672c\u4eba\u306e\u5f15\u7528\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff09",
132
136
  findings: "\u4e3b\u306a\u6240\u898b", disagreements: "\u5206\u6790\u62c5\u5f53\u3068\u306e\u76f8\u9055", change: "\u5224\u65ad\u304c\u5909\u308f\u308b\u6761\u4ef6", sources: "\u51fa\u5178\u307e\u305f\u306f\u660e\u793a\u7684\u306a\u6b20\u843d",
133
- abstainLead: (n) => `\u4ed6\u306b ${n} \u5e2d\u306f\u4eca\u56de\u65b9\u5411\u6027\u3092\u793a\u3057\u307e\u305b\u3093\u3002\u3044\u305a\u308c\u3082\u30e1\u30bd\u30c3\u30c9\u306b\u5fc5\u8981\u306a\u5165\u529b\u3092\u6b20\u3044\u3066\u304a\u308a\u3001\u5f31\u6c17\u7968\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff1a`,
137
+ abstainLead: (n) => `\u4ed6\u306b ${n} \u5e2d\u306f\u3001\u30e1\u30bd\u30c3\u30c9\u306b\u5fc5\u8981\u306a\u5165\u529b\u304c\u4eca\u56de\u5c4a\u304b\u306a\u304b\u3063\u305f\u305f\u3081\u65b9\u5411\u6027\u3092\u793a\u3057\u307e\u305b\u3093\u3002\u30c7\u30fc\u30bf\u306e\u6b20\u843d\u3067\u3042\u308a\u3001\u5f31\u6c17\u7968\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff1a`,
138
+ declined: "\u691c\u8a0e\u3057\u305f\u4e0a\u3067\u898b\u9001\u3063\u305f\u5e2d",
139
+ declinedLead: (n) => `\u6b21\u306e ${n} \u5e2d\u306f\u30e1\u30bd\u30c3\u30c9\u3092\u6700\u5f8c\u307e\u3067\u5b9f\u884c\u3057\u3001\u300c\u3053\u308c\u3067\u306f\u306a\u3044\u300d\u3068\u7d50\u8ad6\u3057\u307e\u3057\u305f\u3002\u30c7\u30fc\u30bf\u4e0d\u8db3\u3067\u306f\u306a\u304f\u5224\u65ad\u3067\u3059\uff1a`,
134
140
  },
135
141
  ko: {
136
142
  heading: "\ubc29\ubc95\ub860 \uc88c\uc11d\ubcc4 \ucd9c\ub825", acted: "\ud310\ub2e8\uc744 \ub0b8 \uc88c\uc11d", abstained: "\uc790\uae30 \uc18c\uad00\uc774 \uc544\ub2c8\ub77c\uace0 \ubc1d\ud78c \uc88c\uc11d",
137
143
  stance: "\uc785\uc7a5", intent: "\uc758\ud5a5", origin: "\ubc1c\uc5b8 \ucd9c\ucc98", statement: "\uc774\ubc88 \ubc1c\uc5b8(\ubcf8\uc778 \uc778\uc6a9\uc774 \uc544\ub2d8)",
138
144
  findings: "\ud575\uc2ec \ubc1c\uacac", disagreements: "\ubd84\uc11d\uac00\uc640\uc758 \uc774\uacac", change: "\ud310\ub2e8 \ubcc0\uacbd \uc870\uac74", sources: "\ucd9c\ucc98 \ub610\ub294 \uba85\uc2dc\uc801 \ub370\uc774\ud130 \uacf5\ubc31",
139
- abstainLead: (n) => `\uadf8 \uc678 ${n}\uac1c \uc88c\uc11d\uc740 \uc774\ubc88 \ud68c\ucc28\uc5d0 \ubc29\ud5a5\uc744 \uc81c\uc2dc\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uac01\uac01 \ubc29\ubc95\ub860\uc5d0 \ud544\uc694\ud55c \uc785\ub825\uc774 \uc5c6\uc73c\uba70 \uc57d\uc138 \ud22c\ud45c\uac00 \uc544\ub2d9\ub2c8\ub2e4:`,
145
+ abstainLead: (n) => `\uadf8 \uc678 ${n}\uac1c \uc88c\uc11d\uc740 \ubc29\ubc95\ub860\uc5d0 \ud544\uc694\ud55c \uc785\ub825\uc774 \uc774\ubc88 \ud68c\ucc28\uc5d0 \ub3c4\ucc29\ud558\uc9c0 \uc54a\uc544 \ubc29\ud5a5\uc744 \uc81c\uc2dc\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130 \uacf5\ubc31\uc774\uba70 \uc57d\uc138 \ud22c\ud45c\uac00 \uc544\ub2d9\ub2c8\ub2e4:`,
146
+ declined: "\uac80\ud1a0 \ud6c4 \ucc38\uc5ec\ud558\uc9c0 \uc54a\uae30\ub85c \ud55c \uc88c\uc11d",
147
+ declinedLead: (n) => `\ub2e4\uc74c ${n}\uac1c \uc88c\uc11d\uc740 \ubc29\ubc95\ub860\uc744 \ub05d\uae4c\uc9c0 \uc218\ud589\ud588\uace0 "\uc774\uac83\uc740 \uc544\ub2c8\ub2e4"\ub77c\ub294 \uacb0\ub860\uc5d0 \ub3c4\ub2ec\ud588\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130 \ubd80\uc871\uc774 \uc544\ub2c8\ub77c \ud310\ub2e8\uc785\ub2c8\ub2e4:`,
140
148
  },
141
149
  });
142
150
 
@@ -183,12 +191,25 @@ function renderMasterStatements(run) {
183
191
  const sections = [`### ${copy.heading}`, voiceDisclaimer(run.language)];
184
192
  if (acted.length) sections.push(`#### ${copy.acted}`, acted.map(seatBlock).join("\n\n"));
185
193
  if (abstained.length) {
186
- // One paragraph, not one row per seat. The stable IDs stay visible so the gate and the
187
- // reader can both account for every selected seat without twenty-five identical lines.
188
- const merged = abstained
194
+ // Two reasons wear the same word and they are not the same event. A seat whose method
195
+ // examined the subject and declined has ANSWERED -- Graham finding no asset floor, a
196
+ // volatility seat finding no testable observation -- and reporting that as "missing an
197
+ // input" tells a reader the system broke when the method spoke. A seat whose required
198
+ // fact never arrived genuinely is a gap. Rendering them together made every run read as
199
+ // the second kind, which is the complaint this split exists to answer.
200
+ // One paragraph each, not one row per seat: the stable IDs stay visible so the gate and
201
+ // the reader can both account for every selected seat.
202
+ const merged = (group) => group
189
203
  .map((opinion) => `${masterTitle(opinion.master, run.language)} (\`${opinion.master}\`) \u2014 ${opinion.voice_statement}`)
190
204
  .join(" ");
191
- sections.push(`#### ${copy.abstained}`, `${copy.abstainLead(abstained.length)}\n\n${merged}`);
205
+ const declined = abstained.filter((opinion) => methodDeclined(opinion));
206
+ const ungrounded = abstained.filter((opinion) => !methodDeclined(opinion));
207
+ if (declined.length) {
208
+ sections.push(`#### ${copy.declined}`, `${copy.declinedLead(declined.length)}\n\n${merged(declined)}`);
209
+ }
210
+ if (ungrounded.length) {
211
+ sections.push(`#### ${copy.abstained}`, `${copy.abstainLead(ungrounded.length)}\n\n${merged(ungrounded)}`);
212
+ }
192
213
  }
193
214
  return sections.join("\n\n");
194
215
  }
@@ -202,6 +223,18 @@ function renderMasterStatements(run) {
202
223
  * weakest thing a council produces and the dissenting seat is the informative one. Stating
203
224
  * this next to the opinions is the difference between a bench and a vote count.
204
225
  */
226
+ /**
227
+ * Did the seat's own method reach this, or did the data never arrive?
228
+ *
229
+ * A fired veto and a false eligibility condition are both the method running to completion and
230
+ * returning "not this one". Anything else -- an unmet required fact type, a coverage shortfall,
231
+ * an executor refusal -- is the run failing to give the method what it asked for.
232
+ */
233
+ export function methodDeclined(opinion) {
234
+ const reason = String(opinion?.decision?.reason || opinion?.reason || "");
235
+ return reason === "veto" || reason === "eligibility";
236
+ }
237
+
205
238
  export function masterCorrelationNote(run) {
206
239
  const opinions = run?.master_opinions || [];
207
240
  if (!opinions.length) return "";