alphacouncil-agent 1.0.2 → 1.0.4
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +70 -0
- package/README.md +24 -15
- package/README.zh-CN.md +18 -10
- package/data/authored/core-seats.mjs +8 -4
- package/data/build-profile.v1.json +1 -1
- package/data/persona-v3-build-specs.v1.mjs +1 -1
- package/docs/plans/v1.1-say-something-worth-reading.md +116 -0
- package/knowledge/ai-assisted-solo/experiments/runs/a.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/d13.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/d26.json +9 -9
- package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +4 -4
- package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +12 -12
- package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +13 -13
- package/knowledge/ai-assisted-solo/experiments/simulation-input.json +6 -6
- package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
- package/knowledge/solo-test/masters/master_bogle/decision_policy.json +4 -3
- package/knowledge/solo-test/masters/master_bogle/provisional-index.json +3 -0
- package/knowledge/solo-test/masters/master_bogle/sources.jsonl +1 -1
- package/mcp/lib/constants.mjs +3 -0
- package/mcp/lib/council-options.mjs +8 -4
- package/mcp/lib/fundamentals.mjs +61 -10
- package/mcp/lib/grounding.mjs +67 -1
- package/mcp/lib/insider-ownership.mjs +23 -12
- package/mcp/lib/orchestrator.mjs +10 -2
- package/mcp/lib/personas/engine.mjs +66 -9
- package/mcp/lib/personas-v3/deterministic-executor.mjs +39 -2
- package/mcp/lib/personas-v3/grounding-adapter.mjs +4 -0
- package/mcp/lib/personas-v3/runtime.mjs +9 -2
- package/mcp/lib/screen.mjs +27 -1
- package/mcp/lib/sec.mjs +80 -17
- package/mcp/lib/voice-from-decision.mjs +211 -0
- package/mcp/lib/xbrl-series.mjs +10 -3
- package/package.json +1 -1
package/mcp/lib/orchestrator.mjs
CHANGED
|
@@ -1200,7 +1200,15 @@ export async function groundingForHeadlessRun({ symbol, asOf, grounding, dryRun,
|
|
|
1200
1200
|
let timer;
|
|
1201
1201
|
const controller = Number.isFinite(timeoutMs) ? new AbortController() : null;
|
|
1202
1202
|
try {
|
|
1203
|
-
|
|
1203
|
+
// The budget is handed DOWN so grounding can settle and return what it has. Racing it from
|
|
1204
|
+
// out here discarded a completed quote and a completed screen whenever one feed was slow,
|
|
1205
|
+
// and the analysts then reported a missing ticker for a symbol that had been supplied.
|
|
1206
|
+
// The outer race stays as a backstop, with headroom, for a call that hangs entirely.
|
|
1207
|
+
const work = gather({
|
|
1208
|
+
symbol, asOf,
|
|
1209
|
+
...(Number.isFinite(timeoutMs) ? { budgetMs: timeoutMs } : {}),
|
|
1210
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
1211
|
+
});
|
|
1204
1212
|
if (!Number.isFinite(timeoutMs)) return await work;
|
|
1205
1213
|
return await Promise.race([
|
|
1206
1214
|
work,
|
|
@@ -1209,7 +1217,7 @@ export async function groundingForHeadlessRun({ symbol, asOf, grounding, dryRun,
|
|
|
1209
1217
|
const error = new Error(`quick grounding timed out after ${Math.round(timeoutMs)}ms`);
|
|
1210
1218
|
reject(error);
|
|
1211
1219
|
controller?.abort(error);
|
|
1212
|
-
}, timeoutMs);
|
|
1220
|
+
}, timeoutMs + LIMITS.GROUNDING_SETTLE_HEADROOM_MS);
|
|
1213
1221
|
}),
|
|
1214
1222
|
]);
|
|
1215
1223
|
} catch (error) {
|
|
@@ -13,6 +13,8 @@ import { typedFactPackFromGrounding } from "../personas-v3/grounding-adapter.mjs
|
|
|
13
13
|
import { buildAnonymousPreDecision, freezeAnonymousDecision } from "../personas-v3/runtime.mjs";
|
|
14
14
|
import { executeDeterministicPersonaPolicy } from "../personas-v3/deterministic-executor.mjs";
|
|
15
15
|
import { languageKey, localized } from "../lang.mjs";
|
|
16
|
+
import { displayMasterLabel } from "../markdown.mjs";
|
|
17
|
+
import { voiceFromDecision, voiceFromDecline } from "../voice-from-decision.mjs";
|
|
16
18
|
import {
|
|
17
19
|
declinedOpinion as v2DeclinedOpinion,
|
|
18
20
|
planMasters as planLegacyMasters,
|
|
@@ -77,7 +79,19 @@ function planV3Seat(run, id, pack) {
|
|
|
77
79
|
pack,
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
|
-
|
|
82
|
+
// A seat with SOME of its required facts still runs its own policy.
|
|
83
|
+
//
|
|
84
|
+
// Several seats are authored with a veto that says, in the method's own words, what an absent
|
|
85
|
+
// fact means -- Pabrai passing without a downside floor, Graham without an asset floor. Those
|
|
86
|
+
// facts are also tool inputs, so a missing one used to end the run at this gate and the veto
|
|
87
|
+
// written for exactly that case never executed. The seat then reported "missing X", which is
|
|
88
|
+
// the runtime describing itself rather than the method answering.
|
|
89
|
+
//
|
|
90
|
+
// Vetoes are evaluated before scoring, and every tool, veto and rule already declares its own
|
|
91
|
+
// `on_missing` and `on_uncomputable` behaviour, so letting the policy run does not invent an
|
|
92
|
+
// answer: it reaches the authored one. A seat with NONE of its required facts is still a hard
|
|
93
|
+
// decline -- there is no method left to run.
|
|
94
|
+
if (preDecision.eligibility.status === "out_of_scope") {
|
|
81
95
|
const frozenDecision = freezeAnonymousDecision(preDecision);
|
|
82
96
|
return {
|
|
83
97
|
id,
|
|
@@ -106,9 +120,27 @@ function planV3Seat(run, id, pack) {
|
|
|
106
120
|
decision: v3DecisionRecord(id, pack, preDecision, frozenDecision),
|
|
107
121
|
};
|
|
108
122
|
} catch (error) {
|
|
109
|
-
// A
|
|
110
|
-
//
|
|
111
|
-
//
|
|
123
|
+
// A seat that could not run because a fact it needs is absent has DECLINED, not broken.
|
|
124
|
+
// Partially grounded seats now execute so their authored vetoes can answer, and a seat
|
|
125
|
+
// without such a veto reaches the same clean abstention it always did -- reporting that as
|
|
126
|
+
// a blocked policy would turn "there was nothing to compute with" into "the system
|
|
127
|
+
// failed", which is the confusion this whole pass exists to remove.
|
|
128
|
+
if (preDecision?.eligibility?.status === "insufficient_grounding" && error?.code === "MISSING_TOOL_INPUT") {
|
|
129
|
+
const frozenDecision = freezeAnonymousDecision(preDecision);
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
engine: "v3_method_runtime",
|
|
133
|
+
declined: true,
|
|
134
|
+
reason: preDecision.eligibility.status,
|
|
135
|
+
pack,
|
|
136
|
+
preDecision,
|
|
137
|
+
frozenDecision,
|
|
138
|
+
decision: v3DecisionRecord(id, pack, preDecision, frozenDecision),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// A malformed policy, unknown operation, or arithmetic failure blocks this physical v3
|
|
142
|
+
// seat. The seat still owns the ID and never falls through to a legacy prompt or an
|
|
143
|
+
// LLM-authored stance.
|
|
112
144
|
return {
|
|
113
145
|
id,
|
|
114
146
|
engine: "v3_method_runtime",
|
|
@@ -191,8 +223,12 @@ export function completedMasterOpinion(run, item) {
|
|
|
191
223
|
}
|
|
192
224
|
const result = item.frozenDecision.structured_decision.result;
|
|
193
225
|
const locale = languageKey(run.language);
|
|
194
|
-
|
|
195
|
-
|
|
226
|
+
// The pack's admitted label carries a maturity suffix -- "provisional operator lens" and its
|
|
227
|
+
// translations -- which belongs in the assurance section, not inside a sentence about the
|
|
228
|
+
// company. Left in, every statement read "using the X provisional operator lens method",
|
|
229
|
+
// which is both ungrammatical and a second copy of a disclosure the report already makes.
|
|
230
|
+
const label = displayMasterLabel(item.pack.admitted_label?.[locale]
|
|
231
|
+
|| item.pack.admitted_label?.en || item.id);
|
|
196
232
|
const copy = localized(run.language, {
|
|
197
233
|
en: { withheld: (pct) => `${pct}% coverage; score withheld`, unscored: "not scored", verdict: (stance, reason) => `${label} frozen decision: ${stance} (${reason})`, summary: (score) => `Using the ${label} method on ${run.symbol}, the PersonaPack v3 deterministic policy executed with score ${score}. Typed facts, hard vetoes, and score bands produced this stance; no language model selected it.`, hit: (id) => `score hit: ${id}`, miss: (id) => `score miss: ${id}`, eligibility: (id) => `eligibility condition ${id} becomes satisfied`, veto: (id) => `hard veto ${id} no longer triggers`, score: (id) => `score condition ${id} becomes satisfied` },
|
|
198
234
|
zh: { withheld: (pct) => `覆盖率 ${pct}%,分数被保留不发布`, unscored: "未评分", verdict: (stance, reason) => `${label}的冻结结论:${stance}(${reason})`, summary: (score) => `按${label}方法审视 ${run.symbol}:PersonaPack v3 确定性政策已执行,得分 ${score}。该立场由结构化事实、硬否决和评分带产生,没有让语言模型选择立场。`, hit: (id) => `评分命中:${id}`, miss: (id) => `评分未命中:${id}`, eligibility: (id) => `资格条件 ${id} 变为满足`, veto: (id) => `硬否决 ${id} 不再触发`, score: (id) => `评分条件 ${id} 变为满足` },
|
|
@@ -223,6 +259,16 @@ export function completedMasterOpinion(run, item) {
|
|
|
223
259
|
verdict: copy.verdict(result.stance, result.reason),
|
|
224
260
|
summary: deterministicStatement,
|
|
225
261
|
voice_statement: deterministicStatement,
|
|
262
|
+
// The five fields, composed from the frozen decision rather than written about it: which
|
|
263
|
+
// facts were read and their values, what the tools produced, which scoring conditions held
|
|
264
|
+
// and which did not, and what would move the verdict. A one-line statement told a reader
|
|
265
|
+
// the stance and nothing about how it was reached.
|
|
266
|
+
voice: voiceFromDecision({
|
|
267
|
+
result,
|
|
268
|
+
policy: item.pack.components?.decision_policy,
|
|
269
|
+
factPack: item.preDecision?.fact_pack,
|
|
270
|
+
language: run.language,
|
|
271
|
+
}),
|
|
226
272
|
voice_status: "deterministic_fallback",
|
|
227
273
|
voice_language: run.language,
|
|
228
274
|
statement_origin: "deterministic_policy_fallback",
|
|
@@ -263,8 +309,12 @@ export function declinedMasterOpinion(run, item) {
|
|
|
263
309
|
if (item?.engine !== "v3_method_runtime") return v2DeclinedOpinion(run, item.id, item.decision);
|
|
264
310
|
const eligibility = item.preDecision.eligibility;
|
|
265
311
|
const locale = languageKey(run.language);
|
|
266
|
-
|
|
267
|
-
|
|
312
|
+
// The pack's admitted label carries a maturity suffix -- "provisional operator lens" and its
|
|
313
|
+
// translations -- which belongs in the assurance section, not inside a sentence about the
|
|
314
|
+
// company. Left in, every statement read "using the X provisional operator lens method",
|
|
315
|
+
// which is both ungrammatical and a second copy of a disclosure the report already makes.
|
|
316
|
+
const label = displayMasterLabel(item.pack.admitted_label?.[locale]
|
|
317
|
+
|| item.pack.admitted_label?.en || item.id);
|
|
268
318
|
const instrument = run?.grounding?.instrument;
|
|
269
319
|
const fundOrIndex = instrument?.fund_like === true || instrument?.index_like === true
|
|
270
320
|
|| ["etf", "mutual_fund", "index"].includes(instrument?.asset_type);
|
|
@@ -284,7 +334,14 @@ export function declinedMasterOpinion(run, item) {
|
|
|
284
334
|
verdict: copy.verdict,
|
|
285
335
|
summary: copy.summary(missing),
|
|
286
336
|
voice_statement: deterministicStatement,
|
|
287
|
-
|
|
337
|
+
// The five fields, composed from the frozen decision rather than written about it: which
|
|
338
|
+
// facts were read and their values, what the tools produced, which scoring conditions held
|
|
339
|
+
// and which did not, and what would move the verdict. A one-line statement told a reader
|
|
340
|
+
// the stance and nothing about how it was reached.
|
|
341
|
+
// An abstention is readable too: what the method did have, what it needed, and why it will
|
|
342
|
+
// not substitute a proxy for the difference.
|
|
343
|
+
voice: voiceFromDecline({ eligibility, language: run.language }),
|
|
344
|
+
voice_status: "deterministic_scope",
|
|
288
345
|
voice_language: run.language,
|
|
289
346
|
statement_origin: "deterministic_scope_fallback",
|
|
290
347
|
key_findings: [deterministicStatement],
|
|
@@ -701,9 +701,33 @@ function nativeMetrics(policy, facts, outputs) {
|
|
|
701
701
|
return { metrics, status };
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
+
/** True when a condition reads only typed facts and literals, so no tool has to run first. */
|
|
705
|
+
function readsOnlyFacts(node) {
|
|
706
|
+
if (!node || typeof node !== "object") return true;
|
|
707
|
+
if (Object.hasOwn(node, "output_id")) return false;
|
|
708
|
+
return Object.values(node).every((value) => (
|
|
709
|
+
Array.isArray(value) ? value.every(readsOnlyFacts) : readsOnlyFacts(value)
|
|
710
|
+
));
|
|
711
|
+
}
|
|
712
|
+
|
|
704
713
|
function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
|
|
705
714
|
const facts = new Map(preDecision.fact_pack.facts.map((fact) => [fact.fact_id, fact]));
|
|
706
715
|
const outputs = new Map();
|
|
716
|
+
|
|
717
|
+
// A veto that reads only facts is evaluated before any tool runs.
|
|
718
|
+
//
|
|
719
|
+
// Several seats are authored with a veto that says what an ABSENT fact means -- Pabrai
|
|
720
|
+
// passing without a downside floor, Graham without an asset floor. That same fact is usually
|
|
721
|
+
// a tool input, and a tool declaring `on_missing: "fail"` aborts the whole policy before any
|
|
722
|
+
// veto is reached, so the seat reported a missing input and the author's answer never ran.
|
|
723
|
+
// Hoisting fact-only vetoes changes no arithmetic: they need nothing a tool produces, and a
|
|
724
|
+
// veto that reads a tool output still waits for it.
|
|
725
|
+
const vetoedOnFactsAlone = policy.hard_vetoes.some((record, index) => {
|
|
726
|
+
if (!readsOnlyFacts(record.condition)) return false;
|
|
727
|
+
const early = evaluateCondition(record.condition, facts, outputs, `decision_policy.hard_vetoes[${index}].condition`);
|
|
728
|
+
return early.computable ? Boolean(early.value) : record.on_uncomputable.action === "trigger";
|
|
729
|
+
});
|
|
730
|
+
|
|
707
731
|
const toolById = new Map(tools.map((tool) => [tool.id, tool]));
|
|
708
732
|
const toolTrace = [];
|
|
709
733
|
for (const toolId of pipeline) {
|
|
@@ -712,7 +736,12 @@ function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
|
|
|
712
736
|
const resolved = tool.inputs.map((operand) => resolveOperand(operand, facts, outputs));
|
|
713
737
|
const missing = unique(resolved.flatMap((input) => input.missing_input_ids));
|
|
714
738
|
if (missing.length) {
|
|
715
|
-
|
|
739
|
+
// A tool that cannot run is fatal, EXCEPT when a fact-only veto has already decided this
|
|
740
|
+
// seat. Then the arithmetic it would have fed is irrelevant to the outcome, and aborting
|
|
741
|
+
// reports a missing input in place of the answer the author wrote for exactly this case.
|
|
742
|
+
if (tool.on_missing === "fail" && !vetoedOnFactsAlone) {
|
|
743
|
+
policyFail("MISSING_TOOL_INPUT", `tool ${tool.id} is missing an input`, { tool_id: tool.id, missing_input_ids: missing });
|
|
744
|
+
}
|
|
716
745
|
toolTrace.push({
|
|
717
746
|
tool_id: tool.id,
|
|
718
747
|
tool_version: tool.version,
|
|
@@ -894,7 +923,15 @@ function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
|
|
|
894
923
|
/** Execute and freeze one ready anonymous pre-decision. */
|
|
895
924
|
export function executeDeterministicPersonaPolicy(preDecision) {
|
|
896
925
|
if (!isObject(preDecision) || preDecision.phase !== "anonymous_pre_decision") policyFail("INVALID_PRE_DECISION", "expected an anonymous_pre_decision payload");
|
|
897
|
-
|
|
926
|
+
// `insufficient_grounding` runs. Every tool, veto and rule declares its own `on_missing` and
|
|
927
|
+
// `on_uncomputable` behaviour, and those declarations are how a method states what an absent
|
|
928
|
+
// input means -- several seats are authored with a veto that answers exactly that case.
|
|
929
|
+
// Refusing to execute reported this gate instead of the method's own answer. Nothing is
|
|
930
|
+
// invented: a fact that is absent stays absent, and every reader of it takes its declared
|
|
931
|
+
// path. `out_of_scope` -- no required fact present at all -- is still a hard stop.
|
|
932
|
+
if (!["ready", "insufficient_grounding"].includes(preDecision.eligibility?.status)) {
|
|
933
|
+
policyFail("PRE_DECISION_NOT_READY", `typed-fact gate is ${preDecision.eligibility?.status || "invalid"}`);
|
|
934
|
+
}
|
|
898
935
|
const contract = preDecision.anonymous_method_contract;
|
|
899
936
|
const policy = contract?.decision_policy;
|
|
900
937
|
const tools = contract?.tools;
|
|
@@ -805,6 +805,10 @@ export function adaptGroundingToTypedFacts(grounding, { asOf, knowledgeAsOf = as
|
|
|
805
805
|
macroSeriesFacts(grounding, context);
|
|
806
806
|
fundamentalFacts(grounding, context);
|
|
807
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);
|
|
808
812
|
insiderOwnershipFacts(grounding, context);
|
|
809
813
|
crossMarketFacts(grounding, context);
|
|
810
814
|
basketNewsFacts(grounding, context);
|
|
@@ -528,10 +528,17 @@ export function assertFrozenDecisionIntegrity(frozenDecision) {
|
|
|
528
528
|
return true;
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
-
/**
|
|
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
|
|
541
|
+
if (preDecision.eligibility.status === "out_of_scope") {
|
|
535
542
|
return deepFreeze({
|
|
536
543
|
decision_layer_called: false,
|
|
537
544
|
frozen_decision: freezeAnonymousDecision(preDecision),
|
package/mcp/lib/screen.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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/mcp/lib/xbrl-series.mjs
CHANGED
|
@@ -29,9 +29,14 @@ export const MAX_ALIGNMENT_DRIFT_DAYS = 45;
|
|
|
29
29
|
/**
|
|
30
30
|
* Concept aliases, ordered by preference, on top of the ones sec.mjs already publishes.
|
|
31
31
|
*
|
|
32
|
-
* Aliases inside one entry are the same economic quantity under different taxonomies. They
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Aliases inside one entry are the same economic quantity under different taxonomies. They are
|
|
33
|
+
* never a fallback to a different concept: a missing depreciation tag stays missing.
|
|
34
|
+
*
|
|
35
|
+
* Total liabilities has one sanctioned reconstruction and it is guarded. Subtracting equity
|
|
36
|
+
* from the balance-sheet total is wrong when the two tags disagree about non-controlling
|
|
37
|
+
* interests -- the difference then lands inside "liabilities", silently. Done against the
|
|
38
|
+
* INCLUDING-NCI equity tag the identity is exact, so that tag, and only that tag, may stand in
|
|
39
|
+
* where a filer never tags `Liabilities` directly. See `totalLiabilitiesFor`.
|
|
35
40
|
*/
|
|
36
41
|
export const FUNDAMENTAL_CONCEPTS = Object.freeze({
|
|
37
42
|
revenue: { tags: CONCEPTS.revenue, unit: "USD" },
|
|
@@ -50,6 +55,8 @@ export const FUNDAMENTAL_CONCEPTS = Object.freeze({
|
|
|
50
55
|
},
|
|
51
56
|
current_assets: { tags: ["AssetsCurrent"], unit: "USD" },
|
|
52
57
|
total_liabilities: { tags: ["Liabilities"], unit: "USD" },
|
|
58
|
+
liabilities_and_equity: { tags: ["LiabilitiesAndStockholdersEquity"], unit: "USD" },
|
|
59
|
+
equity_including_nci: { tags: ["StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"], unit: "USD" },
|
|
53
60
|
goodwill: { tags: ["Goodwill"], unit: "USD" },
|
|
54
61
|
intangibles: { tags: ["IntangibleAssetsNetExcludingGoodwill", "FiniteLivedIntangibleAssetsNet"], unit: "USD" },
|
|
55
62
|
cash: {
|