alphacouncil-agent 1.0.1 → 1.0.3

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 (48) 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/CHANGELOG.md +82 -0
  5. package/README.md +24 -15
  6. package/README.zh-CN.md +18 -10
  7. package/data/authored/core-seats.mjs +11 -7
  8. package/data/authored/quant-seats.mjs +2 -2
  9. package/data/authored/value-seats.mjs +4 -4
  10. package/data/build-profile.v1.json +1 -1
  11. package/data/persona-v3-build-specs.v1.mjs +1 -1
  12. package/docs/plans/v1.1-say-something-worth-reading.md +116 -0
  13. package/knowledge/ai-assisted-solo/experiments/runs/a.json +2 -2
  14. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  15. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  16. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +15 -15
  17. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +27 -27
  18. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +18 -18
  19. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +30 -30
  20. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +31 -31
  21. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +9 -9
  22. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  23. package/knowledge/solo-test/masters/master_bogle/decision_policy.json +4 -3
  24. package/knowledge/solo-test/masters/master_bogle/provisional-index.json +3 -0
  25. package/knowledge/solo-test/masters/master_bogle/sources.jsonl +1 -1
  26. package/knowledge/solo-test/masters/master_graham/decision_policy.json +1 -1
  27. package/knowledge/solo-test/masters/master_klarman/decision_policy.json +1 -1
  28. package/knowledge/solo-test/masters/master_pabrai/decision_policy.json +1 -1
  29. package/mcp/lib/basket-news.mjs +181 -0
  30. package/mcp/lib/breadth.mjs +24 -0
  31. package/mcp/lib/constants.mjs +3 -0
  32. package/mcp/lib/cross-market.mjs +180 -0
  33. package/mcp/lib/fundamentals.mjs +11 -0
  34. package/mcp/lib/grounding.mjs +101 -3
  35. package/mcp/lib/industry.mjs +23 -2
  36. package/mcp/lib/insider-ownership.mjs +23 -12
  37. package/mcp/lib/instrument-facts.mjs +3 -0
  38. package/mcp/lib/markdown.mjs +41 -8
  39. package/mcp/lib/orchestrator.mjs +10 -2
  40. package/mcp/lib/personas/engine.mjs +66 -9
  41. package/mcp/lib/personas-v3/deterministic-executor.mjs +39 -2
  42. package/mcp/lib/personas-v3/grounding-adapter.mjs +143 -0
  43. package/mcp/lib/personas-v3/runtime.mjs +9 -2
  44. package/mcp/lib/screen.mjs +27 -1
  45. package/mcp/lib/sec.mjs +80 -17
  46. package/mcp/lib/voice-from-decision.mjs +211 -0
  47. package/package.json +1 -1
  48. package/scripts/lib/persona-v3-solo-formula-pipeline.mjs +11 -0
@@ -571,6 +571,143 @@ function fundamentalFacts(grounding, context) {
571
571
  * across the newest ownership document per reporting owner, and the coverage limits of Section
572
572
  * 16 are real. Labelling it reported would claim a document that does not exist.
573
573
  */
574
+ /**
575
+ * What else a basket is a bet on, as typed facts.
576
+ *
577
+ * Correlation to the broad market decides how much diversification a position actually buys,
578
+ * which is the input Dalio's authored policy already asks for and could never get. Correlation
579
+ * to Korea is the semiconductor cycle read from outside the United States. Sector dispersion is
580
+ * whether one factor or many are repricing the market.
581
+ */
582
+ /**
583
+ * News as counts, never as content.
584
+ *
585
+ * A headline cannot reach a seat's arithmetic: the same symbol would answer differently
586
+ * depending on what was published that morning, and a frozen deterministic stance would stop
587
+ * being reproducible. What IS a fact is how much of the basket generated dated news in a stated
588
+ * window, and how many constituents filed an 8-K in it. Both are counts with a date, and both
589
+ * are the kind of thing an event-driven method legitimately asks for.
590
+ *
591
+ * The headlines themselves stay in the grounding for the report and for the voice worker that
592
+ * explains a stance the seat already reached.
593
+ */
594
+ function basketNewsFacts(grounding, context) {
595
+ const news = grounding?.basket_news;
596
+ if (!news?.available) return;
597
+ const publicAt = timestampAtOrBefore(grounding?.gathered_at || context.asOf, context.cutoff);
598
+ if (!publicAt) return;
599
+ const sourceIdValue = sourceId("news", "basket_window", news.window_days, publicAt.slice(0, 10));
600
+ if (!registerSource(context, {
601
+ source_id: sourceIdValue,
602
+ source_kind: "news_aggregate",
603
+ title: `dated items for the basket's largest holdings over ${news.window_days} days`,
604
+ url: "https://news.google.com/rss",
605
+ public_at: publicAt,
606
+ retrieved_at: grounding.gathered_at || publicAt,
607
+ locator: { window_days: news.window_days, constituents_read: news.constituents_read, industry: news.industry?.id || null },
608
+ })) return;
609
+ const shared = {
610
+ asOf: context.asOf,
611
+ publicAt,
612
+ sources: [sourceIdValue],
613
+ confidence: 0.6,
614
+ derivation: "rederived",
615
+ };
616
+ addUnique(context.facts, context.diagnostics, baseFact({
617
+ ...shared,
618
+ factId: "news.covered_weight",
619
+ valueKind: "ratio",
620
+ value: news.coverage_weight,
621
+ unit: "decimal",
622
+ ratioDenominator: `weight_of_${news.constituents_read}_largest_holdings`,
623
+ derivationToolId: `${ADAPTER_ID}:news:covered_weight`,
624
+ derivationInput: { window_days: news.window_days },
625
+ }));
626
+ addUnique(context.facts, context.diagnostics, baseFact({
627
+ ...shared,
628
+ factId: "news.filing_event_weight",
629
+ valueKind: "ratio",
630
+ value: news.filing_event_weight,
631
+ unit: "decimal",
632
+ ratioDenominator: `weight_of_${news.constituents_read}_largest_holdings`,
633
+ derivationToolId: `${ADAPTER_ID}:news:filing_event_weight`,
634
+ derivationInput: { window_days: news.window_days, filers: news.filing_event_count },
635
+ }));
636
+ }
637
+
638
+ function crossMarketFacts(grounding, context) {
639
+ const rows = grounding?.cross_market;
640
+ const stamp = grounding?.gathered_at || context.asOf;
641
+ const publicAt = timestampAtOrBefore(stamp, context.cutoff);
642
+ if (Array.isArray(rows) && publicAt) {
643
+ for (const row of rows) {
644
+ if (!finite(row?.correlation)) continue;
645
+ const factId = CROSS_MARKET_FACTS[row.reference];
646
+ if (!factId) continue;
647
+ const sourceIdValue = sourceId("market", "cross_correlation", row.reference, row.to);
648
+ if (!registerSource(context, {
649
+ source_id: sourceIdValue,
650
+ source_kind: "market_snapshot",
651
+ title: `daily closes for ${row.label}`,
652
+ url: `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(row.reference)}?range=1y&interval=1d`,
653
+ public_at: publicAt,
654
+ retrieved_at: stamp,
655
+ locator: { reference: row.reference, from: row.from, to: row.to, sessions: row.sessions },
656
+ })) continue;
657
+ addUnique(context.facts, context.diagnostics, baseFact({
658
+ factId,
659
+ valueKind: "ratio",
660
+ value: row.correlation,
661
+ unit: "decimal",
662
+ ratioDenominator: `pearson_over_${row.sessions}_paired_sessions`,
663
+ asOf: context.asOf,
664
+ publicAt,
665
+ sources: [sourceIdValue],
666
+ confidence: 0.8,
667
+ derivation: "rederived",
668
+ derivationToolId: `${ADAPTER_ID}:cross_market:${row.reference}`,
669
+ derivationInput: { reference: row.reference, from: row.from, to: row.to, relative_return: row.relative_return },
670
+ }));
671
+ }
672
+ }
673
+ const dispersion = grounding?.sector_dispersion;
674
+ if (dispersion?.available && finite(dispersion.dispersion) && publicAt) {
675
+ const sourceIdValue = sourceId("market", "sector_dispersion", dispersion.to);
676
+ if (registerSource(context, {
677
+ source_id: sourceIdValue,
678
+ source_kind: "market_snapshot",
679
+ title: "Select Sector SPDR daily closes",
680
+ url: "https://query1.finance.yahoo.com/v8/finance/chart/XLK?range=1y&interval=1d",
681
+ public_at: publicAt,
682
+ retrieved_at: stamp,
683
+ locator: { sectors: dispersion.measured, from: dispersion.from, to: dispersion.to },
684
+ })) {
685
+ addUnique(context.facts, context.diagnostics, baseFact({
686
+ factId: "market.sector_dispersion",
687
+ valueKind: "ratio",
688
+ value: dispersion.dispersion,
689
+ unit: "decimal",
690
+ ratioDenominator: `stdev_of_${dispersion.measured}_sector_total_returns`,
691
+ asOf: context.asOf,
692
+ publicAt,
693
+ sources: [sourceIdValue],
694
+ confidence: 0.8,
695
+ derivation: "rederived",
696
+ derivationToolId: `${ADAPTER_ID}:sector_dispersion`,
697
+ derivationInput: { leader: dispersion.leader?.symbol, laggard: dispersion.laggard?.symbol, from: dispersion.from, to: dispersion.to },
698
+ }));
699
+ }
700
+ }
701
+ }
702
+
703
+ /** Reference market -> the fact id its correlation is published under. */
704
+ const CROSS_MARKET_FACTS = Object.freeze({
705
+ "^GSPC": "market.correlation_to_broad_market",
706
+ "^KS11": "market.correlation_to_kospi",
707
+ "^KQ11": "market.correlation_to_kosdaq",
708
+ "^SOX": "market.correlation_to_semiconductors",
709
+ });
710
+
574
711
  function insiderOwnershipFacts(grounding, context) {
575
712
  const owned = grounding?.insider_ownership;
576
713
  if (!owned || !finite(owned.value)) return;
@@ -668,7 +805,13 @@ export function adaptGroundingToTypedFacts(grounding, { asOf, knowledgeAsOf = as
668
805
  macroSeriesFacts(grounding, context);
669
806
  fundamentalFacts(grounding, context);
670
807
  instrumentAggregateFacts(grounding, context);
808
+ // The same converter, over the market-level block a company run now carries. Reusing it
809
+ // rather than writing a second one keeps one set of lineage and unit rules for facts that
810
+ // are the same facts, whoever the subject is.
811
+ instrumentAggregateFacts({ instrument_aggregate: grounding?.market_valuation, gathered_at: grounding?.gathered_at }, context);
671
812
  insiderOwnershipFacts(grounding, context);
813
+ crossMarketFacts(grounding, context);
814
+ basketNewsFacts(grounding, context);
672
815
  for (const family of ["screen", "macro", "market"]) {
673
816
  if (grounding?.[family] && !grounding[family].public_at) {
674
817
  if (family === "screen" && context.diagnostics.some((item) => String(item.source || "").startsWith("screen."))) continue;
@@ -528,10 +528,17 @@ export function assertFrozenDecisionIntegrity(frozenDecision) {
528
528
  return true;
529
529
  }
530
530
 
531
- /** Dispatch only an eligible anonymous decision. Missing required facts never call the layer. */
531
+ /**
532
+ * Dispatch a decision the seat can still reach.
533
+ *
534
+ * `out_of_scope` -- none of the required facts present -- never calls the layer, because there
535
+ * is no method left to run. `insufficient_grounding` does: the policy's own vetoes and
536
+ * `on_missing` rules are how a method states what an absent input means, and refusing to
537
+ * execute them reported the runtime's own gate instead of the method's answer.
538
+ */
532
539
  export async function runAnonymousDecisionLayer(preDecision, decisionLayer) {
533
540
  verifyAnonymousPreDecision(preDecision);
534
- if (preDecision.eligibility.status !== "ready") {
541
+ if (preDecision.eligibility.status === "out_of_scope") {
535
542
  return deepFreeze({
536
543
  decision_layer_called: false,
537
544
  frozen_decision: freezeAnonymousDecision(preDecision),
@@ -69,10 +69,36 @@ function ruleProvenance(series) {
69
69
  * Each returns { id, passed, value, threshold, reason } or { skipped } when the inputs
70
70
  * are not available -- a missing input is never silently treated as a pass.
71
71
  */
72
+ /**
73
+ * Gross profit, direct where the filer tagged it and revenue less cost of revenue where not.
74
+ *
75
+ * The subtraction is the filer's own arithmetic on the filer's own figures, matched by period
76
+ * end so a year is never differenced against a different year. Years the filer did tag keep the
77
+ * tagged value: a direct number always beats a reconstructed one.
78
+ */
79
+ export function derivedGrossProfit(direct, revenue, cost) {
80
+ if (direct?.length) return direct;
81
+ if (!revenue?.length || !cost?.length) return direct || [];
82
+ const costByEnd = new Map(cost.map((row) => [row.end, row]));
83
+ const derived = [];
84
+ for (const row of revenue) {
85
+ const paired = costByEnd.get(row.end);
86
+ if (!paired || !Number.isFinite(paired.val)) continue;
87
+ derived.push({
88
+ ...row,
89
+ val: row.val - paired.val,
90
+ tag: `${row.tag} - ${paired.tag}`,
91
+ derived_from: [row.tag, paired.tag],
92
+ });
93
+ }
94
+ return derived;
95
+ }
96
+
72
97
  export function evaluateRules(facts, { asOf = null } = {}) {
73
98
  const revenue = values(facts, "revenue", asOf);
74
99
  const netIncome = values(facts, "netIncome", asOf);
75
- const grossProfit = values(facts, "grossProfit", asOf);
100
+ // Derived where the filer did not tag it directly, from two figures it did publish.
101
+ const grossProfit = derivedGrossProfit(values(facts, "grossProfit", asOf), values(facts, "revenue", asOf), values(facts, "costOfRevenue", asOf));
76
102
  const operatingIncome = values(facts, "operatingIncome", asOf);
77
103
  const ocf = values(facts, "operatingCashFlow", asOf);
78
104
  const capex = values(facts, "capex", asOf);
package/mcp/lib/sec.mjs CHANGED
@@ -31,21 +31,54 @@ async function throttle() {
31
31
  lastCall = Date.now();
32
32
  }
33
33
 
34
- async function secJson(url, timeoutMs = LIMITS.QUOTE_FETCH_MS * 2, upstreamSignal) {
35
- await throttle();
36
- const abort = linkedAbort(timeoutMs, upstreamSignal);
37
- try {
38
- const res = await fetch(url, {
39
- signal: abort.signal,
40
- headers: { "User-Agent": UA, Accept: "application/json" },
41
- });
42
- if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
43
- const text = await res.text();
44
- if (text.trimStart().startsWith("<")) throw new Error(`SEC returned HTML rather than JSON for ${url} (rate limited or blocked)`);
45
- return JSON.parse(text);
46
- } finally {
47
- abort.cleanup();
34
+ /**
35
+ * SEC answers 429 when a client outruns its guidance, and the throttle above only paces a
36
+ * single process -- a second one, or a burst of look-through fetches, still crosses the line.
37
+ * Without a backoff the whole evidence chain for a run collapses on one rate-limited response,
38
+ * which is indistinguishable in the report from the data not existing.
39
+ *
40
+ * Deliberately short and bounded: three attempts over about a second and a half. A caller that
41
+ * is genuinely over budget should fail fast and be told, not stall a research run.
42
+ */
43
+ const RATE_LIMIT_ATTEMPTS = 3;
44
+ const RATE_LIMIT_BACKOFF_MS = 400;
45
+
46
+ function isRateLimited(status) {
47
+ return status === 429 || status === 503;
48
+ }
49
+
50
+ async function withRateLimitRetry(attempt) {
51
+ let lastError = null;
52
+ for (let tries = 0; tries < RATE_LIMIT_ATTEMPTS; tries += 1) {
53
+ if (tries > 0) await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_BACKOFF_MS * (2 ** (tries - 1))));
54
+ const outcome = await attempt();
55
+ if (!outcome.rateLimited) return outcome;
56
+ lastError = outcome.error;
48
57
  }
58
+ throw lastError;
59
+ }
60
+
61
+ async function secJson(url, timeoutMs = LIMITS.QUOTE_FETCH_MS * 2, upstreamSignal) {
62
+ const outcome = await withRateLimitRetry(async () => {
63
+ await throttle();
64
+ const abort = linkedAbort(timeoutMs, upstreamSignal);
65
+ try {
66
+ const res = await fetch(url, {
67
+ signal: abort.signal,
68
+ headers: { "User-Agent": UA, Accept: "application/json" },
69
+ });
70
+ if (isRateLimited(res.status)) {
71
+ return { rateLimited: true, error: new Error(`HTTP ${res.status} for ${url}`) };
72
+ }
73
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
74
+ const text = await res.text();
75
+ if (text.trimStart().startsWith("<")) throw new Error(`SEC returned HTML rather than JSON for ${url} (rate limited or blocked)`);
76
+ return { rateLimited: false, value: JSON.parse(text) };
77
+ } finally {
78
+ abort.cleanup();
79
+ }
80
+ });
81
+ return outcome.value;
49
82
  }
50
83
 
51
84
  /** Raw filing index for one registrant. Callers that need form types read this, not `fetchSubmissions`. */
@@ -188,12 +221,42 @@ export const CONCEPTS = {
188
221
  ],
189
222
  netIncome: ["NetIncomeLoss", "ProfitLoss"],
190
223
  grossProfit: ["GrossProfit"],
191
- operatingIncome: ["OperatingIncomeLoss"],
224
+ // Most filers never tag GrossProfit -- ten of fifteen large caps tested do not -- and report
225
+ // revenue and cost of revenue separately instead. The margin is derived from those when the
226
+ // direct tag is absent, which is arithmetic the filer already published rather than an
227
+ // estimate. Without this, a gross-margin rule was unavailable for two thirds of the market
228
+ // and every seat that reads it fell silent.
229
+ costOfRevenue: [
230
+ "CostOfGoodsAndServicesSold",
231
+ "CostOfRevenue",
232
+ "CostOfGoodsSold",
233
+ "CostOfServices",
234
+ "CostOfGoodsAndServicesSoldExcludingDepreciationDepletionAndAmortization",
235
+ ],
236
+ operatingIncome: ["OperatingIncomeLoss", "OperatingIncomeLossIncludingEquityMethodInvestments"],
192
237
  operatingCashFlow: ["NetCashProvidedByUsedInOperatingActivities", "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations"],
193
- capex: ["PaymentsToAcquirePropertyPlantAndEquipment", "PaymentsToAcquireProductiveAssets"],
238
+ // Capital expenditure has no single tag in practice. Corning files PaymentsForCapitalImprovements
239
+ // and nothing this list previously contained, which alone removed owner earnings -- and with
240
+ // it Buffett and Ackman -- for every issuer that tags it that way.
241
+ capex: [
242
+ "PaymentsToAcquirePropertyPlantAndEquipment",
243
+ "PaymentsToAcquireProductiveAssets",
244
+ "PaymentsForCapitalImprovements",
245
+ "PaymentsForProceedsFromProductiveAssets",
246
+ "PaymentsToAcquireOtherPropertyPlantAndEquipment",
247
+ "PaymentsToAcquireMachineryAndEquipment",
248
+ ],
194
249
  equity: ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
195
250
  assets: ["Assets"],
196
- interestExpense: ["InterestExpense", "InterestIncomeExpenseNet", "InterestExpenseDebt"],
251
+ interestExpense: [
252
+ "InterestExpense",
253
+ "InterestIncomeExpenseNet",
254
+ "InterestExpenseDebt",
255
+ "InterestExpenseNonoperating",
256
+ "InterestAndDebtExpense",
257
+ "InterestIncomeExpenseNonoperatingNet",
258
+ "InterestExpenseBorrowings",
259
+ ],
197
260
  sharesOutstanding: ["CommonStockSharesOutstanding", "WeightedAverageNumberOfDilutedSharesOutstanding"],
198
261
  };
199
262
 
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The five-field voice, composed from the frozen decision rather than written about it.
3
+ *
4
+ * A seat that computed a real verdict from real facts was reporting it in one sentence, and a
5
+ * reader could not see what it had looked at. The five fields existed and nothing filled them.
6
+ *
7
+ * The deterministic record already holds everything they need: which facts were read and what
8
+ * they were, which tools ran and what they produced, which scoring rules hit and missed, which
9
+ * vetoes fired, the band the score landed in, and the conditions that would move it. Composing
10
+ * the fields FROM that record has a property that asking a model to restate a sentence does
11
+ * not: the explanation cannot drift from the verdict, because it is derived from it. If the
12
+ * text says a rule missed, the rule missed.
13
+ *
14
+ * Nothing here is a claim about a named person. It is a rendering of arithmetic the seat's own
15
+ * policy performed, in the report's language.
16
+ */
17
+
18
+ import { localized } from "./lang.mjs";
19
+ import { defaultIntentForStance } from "./voice.mjs";
20
+
21
+ const MAX_ITEMS = 4;
22
+
23
+ function finite(value) {
24
+ return typeof value === "number" && Number.isFinite(value);
25
+ }
26
+
27
+ /** Numbers a reader can scan: decimals stay short, big money keeps its magnitude. */
28
+ export function readableValue(fact) {
29
+ const value = fact?.value;
30
+ if (!finite(value)) return String(value ?? "—");
31
+ if (fact.unit === "decimal" && Math.abs(value) < 10) return `${(value * 100).toFixed(1)}%`;
32
+ if (Math.abs(value) >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
33
+ if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
34
+ return Number(value.toFixed(4)).toString();
35
+ }
36
+
37
+ /** Every fact id a condition reads, in the order it reads them. */
38
+ export function factsInCondition(node, into = []) {
39
+ if (!node || typeof node !== "object") return into;
40
+ if (typeof node.fact_id === "string") into.push(node.fact_id);
41
+ for (const value of Object.values(node)) {
42
+ if (Array.isArray(value)) value.forEach((entry) => factsInCondition(entry, into));
43
+ else if (value && typeof value === "object") factsInCondition(value, into);
44
+ }
45
+ return into;
46
+ }
47
+
48
+ function copyFor(language) {
49
+ return localized(language, {
50
+ en: {
51
+ readFacts: (list) => `Facts this method read: ${list}.`,
52
+ noFacts: "The point-in-time record supplied none of the facts this method reads.",
53
+ computed: (list) => `It computed ${list}.`,
54
+ hits: (n, total, list) => `${n} of ${total} scoring conditions held: ${list}.`,
55
+ misses: (list) => `Conditions that did not hold: ${list}.`,
56
+ noRules: "No scoring condition could be evaluated on what was available.",
57
+ veto: (list) => `A hard veto decided this before scoring: ${list}.`,
58
+ band: (stance, ratio) => `The score of ${ratio} places it in the ${stance} band.`,
59
+ intent: (label) => `On this evidence the method's position is: ${label}.`,
60
+ changes: (list) => `This changes if: ${list}.`,
61
+ noChanges: "No stated condition would move this within the method's own rules.",
62
+ disagree: "This is a single method run on frozen facts. It is not a forecast, and where it differs from the analyst sections the difference is the method's scope, not a correction of them.",
63
+ declineWhat: (list) => `This method requires ${list}, and the point-in-time record did not carry ${list === "" ? "them" : "all of it"}.`,
64
+ declineWhy: "It abstains rather than substituting a proxy, because a number the method did not compute is not the method's answer.",
65
+ },
66
+ zh: {
67
+ readFacts: (list) => `本方法读取的事实:${list}。`,
68
+ noFacts: "时点一致的记录没有提供本方法需要的任何事实。",
69
+ computed: (list) => `据此计算得到:${list}。`,
70
+ hits: (n, total, list) => `${total} 条评分条件中有 ${n} 条成立:${list}。`,
71
+ misses: (list) => `未成立的条件:${list}。`,
72
+ noRules: "在可得资料上没有任何评分条件能够求值。",
73
+ veto: (list) => `在评分之前已由硬否决决定:${list}。`,
74
+ band: (stance, ratio) => `得分 ${ratio},落在「${stance}」档。`,
75
+ intent: (label) => `基于这些证据,本方法的立场是:${label}。`,
76
+ changes: (list) => `以下情况会改变这一判断:${list}。`,
77
+ noChanges: "在本方法自身的规则内,没有任何已陈述的条件会改变它。",
78
+ disagree: "这是一次在冻结事实上运行的单一方法,不是预测。与分析师各席的差异来自方法的适用范围,不构成对他们的更正。",
79
+ declineWhat: (list) => `本方法需要 ${list},而时点一致的记录没有全部提供。`,
80
+ declineWhy: "它选择弃权而不是用替代值补位,因为方法没有计算出来的数字不是方法的答案。",
81
+ },
82
+ ja: {
83
+ readFacts: (list) => `この手法が読んだ事実:${list}。`,
84
+ noFacts: "時点整合した記録に、この手法が読む事実はありませんでした。",
85
+ computed: (list) => `そこから算出:${list}。`,
86
+ hits: (n, total, list) => `${total} 件の採点条件のうち ${n} 件が成立:${list}。`,
87
+ misses: (list) => `成立しなかった条件:${list}。`,
88
+ noRules: "利用可能な資料では採点条件を評価できませんでした。",
89
+ veto: (list) => `採点の前にハード拒否で決着:${list}。`,
90
+ band: (stance, ratio) => `スコア ${ratio}、「${stance}」の帯に入ります。`,
91
+ intent: (label) => `この証拠に基づく手法の立場:${label}。`,
92
+ changes: (list) => `次の場合に判断は変わります:${list}。`,
93
+ noChanges: "手法自身の規則の範囲では、判断を動かす条件は示されていません。",
94
+ disagree: "凍結された事実に対する単一手法の実行であり、予測ではありません。アナリスト各席との差異は手法の適用範囲によるもので、訂正ではありません。",
95
+ declineWhat: (list) => `この手法は ${list} を必要としますが、時点整合した記録が揃いませんでした。`,
96
+ declineWhy: "代替値で埋めず棄権します。手法が計算していない数値は手法の答えではないからです。",
97
+ },
98
+ ko: {
99
+ readFacts: (list) => `이 방법이 읽은 사실: ${list}.`,
100
+ noFacts: "시점이 일치하는 기록에 이 방법이 읽는 사실이 없었습니다.",
101
+ computed: (list) => `이로부터 산출: ${list}.`,
102
+ hits: (n, total, list) => `채점 조건 ${total}개 중 ${n}개 성립: ${list}.`,
103
+ misses: (list) => `성립하지 않은 조건: ${list}.`,
104
+ noRules: "확보된 자료로는 어떤 채점 조건도 평가할 수 없었습니다.",
105
+ veto: (list) => `채점 전에 하드 비토로 결정됨: ${list}.`,
106
+ band: (stance, ratio) => `점수 ${ratio}, "${stance}" 구간에 해당합니다.`,
107
+ intent: (label) => `이 증거에서 이 방법의 입장: ${label}.`,
108
+ changes: (list) => `다음의 경우 판단이 바뀝니다: ${list}.`,
109
+ noChanges: "방법 자체의 규칙 안에서 판단을 움직일 조건은 제시되지 않았습니다.",
110
+ disagree: "동결된 사실에 대한 단일 방법 실행이며 예측이 아닙니다. 애널리스트 좌석과의 차이는 방법의 적용 범위 차이이지 정정이 아닙니다.",
111
+ declineWhat: (list) => `이 방법은 ${list}이 필요하지만 시점 일치 기록이 이를 모두 담고 있지 않았습니다.`,
112
+ declineWhy: "대체값으로 메우지 않고 기권합니다. 방법이 계산하지 않은 숫자는 방법의 답이 아니기 때문입니다.",
113
+ },
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Compose the five fields for a seat that reached a stance.
119
+ *
120
+ * `result` is the frozen `structured_decision.result`; `policy` is the seat's decision policy,
121
+ * which supplies the human-readable condition ids the result refers to by index.
122
+ */
123
+ export function voiceFromDecision({ result, policy, factPack, language = "English" } = {}) {
124
+ const copy = copyFor(language);
125
+ const facts = new Map((factPack?.facts || []).map((fact) => [fact.fact_id, fact]));
126
+ const stance = result?.common_projection?.stance || "out_of_scope";
127
+
128
+ const readIds = [...new Set([
129
+ ...(policy?.scoring?.rules || []).flatMap((rule) => factsInCondition(rule.condition)),
130
+ ...(policy?.hard_vetoes || []).flatMap((veto) => factsInCondition(veto.condition)),
131
+ ...(policy?.eligibility?.all || []).flatMap((check) => factsInCondition(check.condition)),
132
+ ])].filter((id) => facts.has(id));
133
+ const readList = readIds.slice(0, MAX_ITEMS)
134
+ .map((id) => `${id} = ${readableValue(facts.get(id))}`).join("; ");
135
+
136
+ const computed = (result?.computations?.trace || [])
137
+ .filter((step) => step.status === "computed" && finite(step.value))
138
+ .slice(0, MAX_ITEMS)
139
+ .map((step) => `${step.output_id} = ${Number(step.value.toFixed(4))}`)
140
+ .join("; ");
141
+
142
+ // The executor publishes the rules it evaluated already split into hits, misses and the ones
143
+ // it could not compute. Reading its own split rather than re-deriving one is what keeps this
144
+ // text unable to disagree with the verdict it describes.
145
+ const scoring = result?.score || {};
146
+ const hits = scoring.hits || [];
147
+ const misses = scoring.misses || [];
148
+ const uncomputable = scoring.uncomputable || [];
149
+ const evaluated = [...hits, ...misses, ...uncomputable];
150
+ // The runtime anonymises rule ids so the decision layer cannot recognise the seat. A reader
151
+ // is not the decision layer: map each anonymous id back to the readable name in the seat's
152
+ // own policy, which lists its rules in the same order the executor evaluated them.
153
+ const readableRuleIds = new Map((policy?.scoring?.rules || [])
154
+ .map((rule, index) => [index, rule.rule_id])
155
+ .filter(([, id]) => typeof id === "string"));
156
+ const anonOrder = new Map(evaluated
157
+ .map((rule) => rule.rule_id)
158
+ .filter(Boolean)
159
+ .map((id, index) => [id, index]));
160
+ const ruleName = (rule) => {
161
+ const anon = rule.rule_id || rule.id;
162
+ const readable = readableRuleIds.get(anonOrder.get(anon));
163
+ return readable || anon || "rule";
164
+ };
165
+
166
+ const vetoes = (result?.vetoes_triggered || []).map((veto) => veto.veto_id).filter(Boolean);
167
+
168
+ const whatISee = [
169
+ readList ? copy.readFacts(readList) : copy.noFacts,
170
+ computed ? copy.computed(computed) : "",
171
+ ].filter(Boolean).join(" ");
172
+
173
+ const howItReads = [
174
+ vetoes.length ? copy.veto(vetoes.join("; ")) : "",
175
+ evaluated.length
176
+ ? copy.hits(hits.length, evaluated.length, hits.map(ruleName).join("; ") || "—")
177
+ : copy.noRules,
178
+ misses.length ? copy.misses(misses.map(ruleName).join("; ")) : "",
179
+ finite(result?.common_projection?.score_ratio)
180
+ ? copy.band(stance, Number(result.common_projection.score_ratio.toFixed(3)))
181
+ : "",
182
+ ].filter(Boolean).join(" ");
183
+
184
+ const changes = [
185
+ ...misses.map(ruleName),
186
+ ...uncomputable.map(ruleName),
187
+ ...vetoes,
188
+ ].slice(0, MAX_ITEMS);
189
+
190
+ return {
191
+ what_i_see: whatISee,
192
+ how_my_method_reads_it: howItReads,
193
+ would_i_act: copy.intent(defaultIntentForStance(stance)),
194
+ what_changes_my_mind: changes.length ? copy.changes(changes.join("; ")) : copy.noChanges,
195
+ where_i_disagree: copy.disagree,
196
+ };
197
+ }
198
+
199
+ /** The same five fields for a seat that could not run, so an abstention is also readable. */
200
+ export function voiceFromDecline({ eligibility, language = "English" } = {}) {
201
+ const copy = copyFor(language);
202
+ const missing = (eligibility?.missing_required_fact_types || []).slice(0, MAX_ITEMS).join("; ");
203
+ const present = (eligibility?.present_required_fact_types || []).slice(0, MAX_ITEMS).join("; ");
204
+ return {
205
+ what_i_see: present ? copy.readFacts(present) : copy.noFacts,
206
+ how_my_method_reads_it: copy.declineWhat(missing),
207
+ would_i_act: copy.intent(defaultIntentForStance("out_of_scope")),
208
+ what_changes_my_mind: missing ? copy.changes(missing) : copy.noChanges,
209
+ where_i_disagree: copy.declineWhy,
210
+ };
211
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alphacouncil-agent",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Multi-agent company, ETF and market-index research workflow for Codex & Claude Code: sourced evidence, method seats, bull/bear debate and portfolio-manager synthesis.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -126,6 +126,17 @@ export const CANONICAL_SOLO_TEST_FACT_CONTRACTS = Object.freeze({
126
126
  "fund.aum": Object.freeze({ value_kind: "monetary", unit: "currency_units", period: INSTANT_AS_OF }),
127
127
  "fund.expense_ratio": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
128
128
  // Creations minus redemptions, priced. A crowding signal, never a valuation one.
129
+ // What else the holding is a bet on. Correlation decides what a position actually
130
+ // diversifies; dispersion says whether one factor or many are repricing the market.
131
+ "market.correlation_to_broad_market": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
132
+ "market.correlation_to_kospi": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
133
+ "market.correlation_to_kosdaq": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
134
+ "market.correlation_to_semiconductors": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
135
+ // News as counts. The headlines never enter a computation; how much of the basket is in the
136
+ // news, and how much of it filed, are dated quantities an event-driven method may read.
137
+ "news.covered_weight": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
138
+ "news.filing_event_weight": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
139
+ "market.sector_dispersion": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
129
140
  "fund.net_flow": Object.freeze({ value_kind: "monetary", unit: "currency_units", period: INSTANT_AS_OF }),
130
141
  "fund.net_flow_ratio": Object.freeze({ value_kind: "ratio", unit: "decimal", period: INSTANT_AS_OF }),
131
142