alphacouncil-agent 0.6.0 → 0.8.0

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.
@@ -13,6 +13,7 @@ import { debateFromCodex, dryDebate, dryPacket, extractJson, managerFallback, me
13
13
  import { mapLimit, runCodex } from "./codex.mjs";
14
14
  import { debatePrompt, masterPrompt, selectedMasters, taskPrompt } from "./prompts.mjs";
15
15
  import { resolveSeatWeights } from "./weights.mjs";
16
+ import { declinedOpinion, planMasters, reconcileOpinion } from "./personas-v2/bridge.mjs";
16
17
 
17
18
  export function visibleRun(args) {
18
19
  const symbol = safeSymbol(args.symbol);
@@ -80,21 +81,83 @@ export function visibleAgentSpecs(run, userPrompt = "") {
80
81
  ].filter(Boolean).join("\n"),
81
82
  output_contract: isChineseLanguage(run.language) ? "只返回一个 JSON debate packet。" : `Return one JSON debate packet with reader-facing fields in ${run.language}.`,
82
83
  }));
83
- const master_agents = selectedMasters(run).map((id) => ({
84
+ // The deterministic pass runs first and settles, for free, every seat whose method cannot
85
+ // reach this security. Spawning an agent for a lens that has already declined is how the
86
+ // previous design produced ten confident essays over a screen that computed nothing.
87
+ const plan = planMasters(run, selectedMasters(run));
88
+ const zh = isChineseLanguage(run.language);
89
+ const master_agents = plan.to_run.map(({ id, decision }) => ({
84
90
  role: id,
91
+ engine: decision ? "v2_method_model" : "v1_prompt",
85
92
  title: `AlphaCouncil Agent ${run.symbol} ${id}`,
86
93
  prompt_template: [
87
94
  masterPrompt(id, run),
88
95
  "",
89
- isChineseLanguage(run.language)
96
+ decision ? deterministicVerdictBlock(decision, zh) : "",
97
+ "",
98
+ zh
90
99
  ? "主线程必须先粘贴已完成的 Evidence JSON,再运行这个大师议席;大师在证据之后、辩论之前运行。"
91
100
  : "The main thread must paste the completed Evidence JSON first. Masters run after the evidence stage and before the debate.",
92
101
  ].filter(Boolean).join("\n"),
93
- output_contract: isChineseLanguage(run.language)
102
+ output_contract: zh
94
103
  ? "只返回一个 JSON master opinion。"
95
104
  : `Return one JSON master opinion with reader-facing fields in ${run.language}.`,
96
105
  }));
97
- return { evidence_agents, master_agents, debate_agents };
106
+ // A declined seat is settled here rather than skipped: it is written straight into the
107
+ // run as an out_of_scope opinion, so the completeness gate is satisfied and no agent is
108
+ // ever spawned for a method that cannot look.
109
+ if (plan.declined.length) {
110
+ const byId = new Map((run.master_opinions || []).map((o) => [o.master, o]));
111
+ for (const { id, decision } of plan.declined) {
112
+ if (!byId.has(id)) byId.set(id, declinedOpinion(run, id, decision));
113
+ }
114
+ run.master_opinions = selectedMasters(run).map((id) => byId.get(id)).filter(Boolean);
115
+ }
116
+ return {
117
+ evidence_agents,
118
+ master_agents,
119
+ debate_agents,
120
+ // Recorded, not hidden: a reader must be able to tell a method that judged from a method
121
+ // that could not look, and neither from a seat that was never offered.
122
+ master_decisions: plan.decisions,
123
+ masters_declined: plan.declined.map(({ id, decision }) => ({
124
+ master: id,
125
+ stance: decision.stance,
126
+ reason: decision.reason,
127
+ unmet: decision.eligibility?.unmet || [],
128
+ })),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * The stance is already decided. The prompt says so, in the run's language, so the model
134
+ * writes an explanation rather than a verdict.
135
+ */
136
+ function deterministicVerdictBlock(decision, zh) {
137
+ const hits = (decision.score?.hits || []).map((h) => `${h.id}=${h.actual} (>=${h.threshold}, +${h.points})`).join("; ") || "none";
138
+ const misses = (decision.score?.misses || []).map((m) => `${m.id}=${m.actual}`).join("; ") || "none";
139
+ const uncomputable = (decision.score?.uncomputable || []).map((u) => u.id).join("; ") || "none";
140
+ return zh
141
+ ? [
142
+ "## 已确定的判决(由确定性政策产生,你不能推翻)",
143
+ `- 立场:${decision.stance}(依据:${decision.reason})`,
144
+ `- 得分:${decision.score?.score ?? "—"}/${decision.score?.max_possible ?? "—"},覆盖率 ${Math.round((decision.score?.coverage || 0) * 100)}%`,
145
+ `- 命中:${hits}`,
146
+ `- 未命中:${misses}`,
147
+ `- 无法计算(既不算命中也不算未命中):${uncomputable}`,
148
+ "",
149
+ "你的任务是解释这个判决为什么成立、它最可能错在哪里、以及什么证据会推翻它。**不要给出与上面不同的 stance**;如果你认为它错了,把理由写进 disagreements。",
150
+ ].join("\n")
151
+ : [
152
+ "## Settled verdict (produced by the deterministic policy; you cannot overturn it)",
153
+ `- Stance: ${decision.stance} (basis: ${decision.reason})`,
154
+ `- Score: ${decision.score?.score ?? "—"}/${decision.score?.max_possible ?? "—"}, coverage ${Math.round((decision.score?.coverage || 0) * 100)}%`,
155
+ `- Hits: ${hits}`,
156
+ `- Misses: ${misses}`,
157
+ `- Uncomputable (neither a hit nor a miss): ${uncomputable}`,
158
+ "",
159
+ "Explain why this verdict holds, where it is most likely wrong, and what evidence would overturn it. **Do not return a different stance**; if you think it is wrong, put the reason in disagreements.",
160
+ ].join("\n");
98
161
  }
99
162
 
100
163
  export function recordMasterOpinion(args) {
@@ -107,20 +170,23 @@ export function recordMasterOpinion(args) {
107
170
  throw invalidParams(`master ${args.master} was not selected for this run. Selected: ${allowed.join(", ") || "none"}`);
108
171
  }
109
172
  const dir = runPath(run.run_id);
110
- const opinion = normalizeMasterOpinion(
173
+ const normalized = normalizeMasterOpinion(
111
174
  { ...(args.packet || {}), thread_id: args.thread_id },
112
175
  args.master,
113
176
  run,
114
177
  rawRecordText(args.packet),
115
178
  );
179
+ // A narrated stance that disagrees with the arithmetic does not get to win quietly. The
180
+ // deterministic verdict stands and the disagreement is preserved on the record.
181
+ const { opinion, overridden } = reconcileOpinion(run, args.master, normalized);
116
182
  const byId = new Map((run.master_opinions || []).map((item) => [item.master, item]));
117
183
  byId.set(args.master, opinion);
118
184
  run.master_opinions = allowed.map((id) => byId.get(id)).filter(Boolean);
119
185
  writeJson(join(dir, `${args.master}.json`), opinion);
120
186
  saveRun(run);
121
187
  writeJson(join(dir, "evidence.json"), run);
122
- appendEvent(run, "master_opinion_recorded", { master: args.master, stance: opinion.stance });
123
- return { run, opinion, recorded: run.master_opinions.length, expected: allowed.length };
188
+ appendEvent(run, "master_opinion_recorded", { master: args.master, stance: opinion.stance, overridden });
189
+ return { run, opinion, overridden, recorded: run.master_opinions.length, expected: allowed.length };
124
190
  }
125
191
 
126
192
  export function visibleStatusAfterPacket(run) {
@@ -217,6 +217,47 @@ export function managerFallback(run, userPrompt = "") {
217
217
  }, "portfolio_manager", run);
218
218
  }
219
219
 
220
+ /**
221
+ * Common ways a caller says a stance that is not one of the four we store.
222
+ *
223
+ * Mapping these is not politeness. An unmapped value used to fall through to "cautious",
224
+ * which is a real stance carrying real weight -- so a caller writing "avoid" got a seat
225
+ * that looked deliberate and voted. Ten such seats render as unanimity that no master
226
+ * produced.
227
+ */
228
+ const STANCE_SYNONYMS = new Map([
229
+ ["long", "constructive"], ["bullish", "constructive"], ["buy", "constructive"],
230
+ ["positive", "constructive"], ["overweight", "constructive"],
231
+ ["neutral", "cautious"], ["hold", "cautious"], ["mixed", "cautious"], ["wait", "cautious"],
232
+ ["short", "opposed"], ["bearish", "opposed"], ["sell", "opposed"], ["avoid", "opposed"],
233
+ ["negative", "opposed"], ["underweight", "opposed"],
234
+ ["n/a", "out_of_scope"], ["na", "out_of_scope"], ["skip", "out_of_scope"],
235
+ ["abstain", "out_of_scope"], ["unknown", "out_of_scope"],
236
+ ]);
237
+
238
+ /**
239
+ * Never silently invent a stance.
240
+ *
241
+ * Anything we cannot map becomes `out_of_scope`, which weights.mjs already treats as
242
+ * carrying zero weight. Guessing "cautious" for an unrecognised value manufactures a
243
+ * confident-looking seat out of a caller's typo; declining to score it does not.
244
+ */
245
+ export function coerceStance(value, masterId = "") {
246
+ if (MASTER_STANCES.includes(value)) return value;
247
+ if (typeof value === "string") {
248
+ const mapped = STANCE_SYNONYMS.get(value.trim().toLowerCase());
249
+ if (mapped) return mapped;
250
+ }
251
+ if (value !== undefined && value !== null && value !== "") {
252
+ process.emitWarning(
253
+ `alphacouncil: unrecognised master stance ${JSON.stringify(value)}`
254
+ + `${masterId ? ` from ${masterId}` : ""}; recorded as out_of_scope (zero weight). `
255
+ + `Allowed: ${MASTER_STANCES.join(", ")}.`
256
+ );
257
+ }
258
+ return "out_of_scope";
259
+ }
260
+
220
261
  /**
221
262
  * A master's opinion. Deliberately NOT a debate packet: a master issues no rating and
222
263
  * declares no winner. out_of_scope is a first-class stance -- "by my method this name is
@@ -229,7 +270,7 @@ export function normalizeMasterOpinion(packet, masterId, run, raw = "") {
229
270
  symbol: run.symbol,
230
271
  as_of: run.as_of,
231
272
  verdict: typeof packet?.verdict === "string" ? packet.verdict : "",
232
- stance: MASTER_STANCES.includes(packet?.stance) ? packet.stance : "cautious",
273
+ stance: coerceStance(packet?.stance, masterId),
233
274
  summary: typeof packet?.summary === "string" ? packet.summary : raw.slice(0, LIMITS.CLEAN_LOG_BYTES),
234
275
  key_findings: list(packet?.key_findings),
235
276
  disagreements: list(packet?.disagreements),
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Experiments whose job is to prove the bench decorative.
3
+ *
4
+ * A feature that cannot fail its own test is a decoration. These are the cheapest and most
5
+ * decisive of the battery in docs/persona-v2-spec.md:
6
+ *
7
+ * name swap -- change only the label. A real method does not notice.
8
+ * policy swap -- run Taleb's policy under Buffett's name. A real system follows the
9
+ * policy; a performance follows the name.
10
+ *
11
+ * They are deterministic because the layer under test is deterministic: no model is called,
12
+ * so a difference in outcome can only come from the module that was swapped.
13
+ */
14
+
15
+ import { decide } from "./policy.mjs";
16
+
17
+ /** Fields a decision may legitimately carry that are identity rather than judgment. */
18
+ const IDENTITY_FIELDS = new Set(["persona_id"]);
19
+
20
+ /** The judgment, with identity stripped, so two decisions can be compared as verdicts. */
21
+ export function judgmentOf(decision) {
22
+ const copy = {};
23
+ for (const [key, value] of Object.entries(decision || {})) {
24
+ if (IDENTITY_FIELDS.has(key)) continue;
25
+ copy[key] = value;
26
+ }
27
+ return JSON.stringify(copy);
28
+ }
29
+
30
+ /**
31
+ * Rename a pack without touching a single rule.
32
+ *
33
+ * If the verdict moves, the differentiation was in the label. In the deterministic layer
34
+ * that is a structural guarantee rather than a hope, and that is the point of testing it
35
+ * here: it pins the guarantee before the narrative layer, which cannot offer one, is added.
36
+ */
37
+ export function nameSwap(pack, newId = "master_impostor", newName = "Impostor Method Model") {
38
+ return { ...pack, persona_id: newId, display_name: { ...pack.display_name, en: newName } };
39
+ }
40
+
41
+ /** Keep the identity, take the other model's decision policy. */
42
+ export function policySwap(pack, donor) {
43
+ return { ...pack, decision_policy: donor.decision_policy, _policy_from: donor.persona_id };
44
+ }
45
+
46
+ /** Keep the identity and policy, take the other model's evidence. */
47
+ export function evidenceSwap(factsA, factsB) {
48
+ return [factsB, factsA];
49
+ }
50
+
51
+ /**
52
+ * @returns {{stable: boolean, before: string, after: string}} stable === the verdict did
53
+ * not move when only the name did. `false` means the system is acting.
54
+ */
55
+ export function runNameSwap(pack, facts) {
56
+ const before = decide(pack, facts);
57
+ const after = decide(nameSwap(pack), facts);
58
+ return {
59
+ experiment: "name_swap",
60
+ stable: judgmentOf(before) === judgmentOf(after),
61
+ before: before.stance,
62
+ after: after.stance,
63
+ persona_id_before: before.persona_id,
64
+ persona_id_after: after.persona_id,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * @returns {{follows_policy: boolean}} true when the verdict tracked the donor policy
70
+ * rather than the host name.
71
+ */
72
+ export function runPolicySwap(host, donor, facts) {
73
+ const hostOwn = decide(host, facts);
74
+ const donorOwn = decide(donor, facts);
75
+ const hybrid = decide(policySwap(host, donor), facts);
76
+ return {
77
+ experiment: "policy_swap",
78
+ host: host.persona_id,
79
+ donor: donor.persona_id,
80
+ host_stance: hostOwn.stance,
81
+ donor_stance: donorOwn.stance,
82
+ hybrid_stance: hybrid.stance,
83
+ follows_policy: judgmentOf(hybrid) === judgmentOf(donorOwn),
84
+ follows_name: judgmentOf(hybrid) === judgmentOf(hostOwn) && judgmentOf(donorOwn) !== judgmentOf(hostOwn),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Do these models actually see different securities?
90
+ *
91
+ * Pairwise agreement across a case set. Compared against a model's agreement with itself,
92
+ * this is what separates a bench from a chorus: if two models agree as often as one model
93
+ * agrees with itself, the personas produced no differentiation and the report has to say so
94
+ * rather than presenting them as independent seats.
95
+ */
96
+ export function pairwiseAgreement(packs, cases) {
97
+ const rows = [];
98
+ for (let i = 0; i < packs.length; i += 1) {
99
+ for (let j = i + 1; j < packs.length; j += 1) {
100
+ let same = 0;
101
+ for (const facts of cases) {
102
+ if (decide(packs[i], facts).stance === decide(packs[j], facts).stance) same += 1;
103
+ }
104
+ rows.push({ a: packs[i].persona_id, b: packs[j].persona_id, agreement: cases.length ? same / cases.length : 0 });
105
+ }
106
+ }
107
+ const mean = rows.length ? rows.reduce((sum, r) => sum + r.agreement, 0) / rows.length : 0;
108
+ return { pairs: rows, mean_agreement: mean };
109
+ }
110
+
111
+ /**
112
+ * Self-consistency of the deterministic layer is 1 by construction. It is computed rather
113
+ * than asserted so the same diagnostic keeps working once a narrative layer -- which has no
114
+ * such guarantee -- sits on top of it.
115
+ */
116
+ export function selfConsistency(pack, cases, repeats = 3) {
117
+ let agree = 0;
118
+ let total = 0;
119
+ for (const facts of cases) {
120
+ const first = decide(pack, facts).stance;
121
+ for (let r = 1; r < repeats; r += 1) {
122
+ total += 1;
123
+ if (decide(pack, facts).stance === first) agree += 1;
124
+ }
125
+ }
126
+ return total ? agree / total : 1;
127
+ }
128
+
129
+ /**
130
+ * The verdict the report must print.
131
+ *
132
+ * `none` means the seats cannot be told apart and must not be presented as a bench. The
133
+ * threshold is a margin, not a hope: differentiation counts only when models disagree with
134
+ * each other appreciably more than a model disagrees with itself.
135
+ */
136
+ export function differentiation(packs, cases, { margin = 0.15 } = {}) {
137
+ const pairwise = pairwiseAgreement(packs, cases);
138
+ const self = packs.length ? packs.reduce((sum, p) => sum + selfConsistency(p, cases), 0) / packs.length : 1;
139
+ const gap = self - pairwise.mean_agreement;
140
+ const verdict = gap >= margin * 2 ? "effective" : gap >= margin ? "weak" : "none";
141
+ return {
142
+ self_consistency: self,
143
+ mean_pairwise_agreement: pairwise.mean_agreement,
144
+ gap,
145
+ verdict,
146
+ pairs: pairwise.pairs,
147
+ };
148
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Where Persona v2 meets a live run.
3
+ *
4
+ * The grounding block a run already assembles carries almost everything a deterministic
5
+ * gate needs; it is simply shaped for prompts rather than for predicates. This maps it onto
6
+ * the fact paths the packs address, and nothing here invents a value: a field the grounding
7
+ * does not carry stays absent, so the gate declines rather than guesses.
8
+ *
9
+ * A pack with no v2 manifest is not an error. Masters that have not been migrated keep
10
+ * running as v1 prompt personas, and the run records which of the two produced each seat so
11
+ * a reader is never left to assume.
12
+ */
13
+
14
+ import { loadPacks } from "./loader.mjs";
15
+ import { decide } from "./policy.mjs";
16
+ import { sliceFor } from "./slice.mjs";
17
+
18
+ let cached = null;
19
+ export function packs() {
20
+ if (!cached) cached = loadPacks();
21
+ return cached;
22
+ }
23
+ export function resetPacks() { cached = null; }
24
+
25
+ /**
26
+ * Grounding -> the fact shape packs address.
27
+ *
28
+ * `screen.rules_computed` is the load-bearing one: a 20-F filer yields zero computable
29
+ * rules, which is exactly the condition a financial-series method must decline on.
30
+ */
31
+ export function factsFromRun(run) {
32
+ const g = run?.grounding || {};
33
+ const screen = g.screen || {};
34
+ const metrics = {};
35
+ for (const metric of screen.metrics || []) {
36
+ if (metric && typeof metric.rule === "string" && metric.value !== undefined) metrics[metric.rule] = metric.value;
37
+ }
38
+ const options = g.options || {};
39
+ return {
40
+ symbol: run?.symbol,
41
+ as_of: run?.as_of,
42
+ quote: g.quote || {},
43
+ filer: {
44
+ ...(g.filer || {}),
45
+ // The screen only computes anything when SEC structured financials cover the filer.
46
+ structured_financials: Number(screen.rules_computed || 0) > 0,
47
+ },
48
+ screen: {
49
+ rules_computed: Number(screen.rules_computed || 0),
50
+ rules_total: Number(screen.rules_total || 0),
51
+ metrics,
52
+ },
53
+ options: {
54
+ chain_available: Boolean(options.atm_iv || options.term_structure || options.expiries),
55
+ atm_iv: options.atm_iv,
56
+ // Mapped only where the snapshot genuinely carries them. Realized volatility, and so
57
+ // the implied-versus-realized gap and any friction-adjusted edge, need a history the
58
+ // chain does not have -- which is why IV percentile is uncomputable too. Those paths
59
+ // stay absent, the rules that read them score as uncomputable, and a volatility method
60
+ // handed a bare snapshot correctly finds too little of itself to run.
61
+ skew_25d_points: options.skew_25d_put_minus_call_points ?? options.skew_25d_points,
62
+ expiry_covers_next_event: options.expiry_covers_next_event,
63
+ realized_minus_implied_vol_points: options.realized_minus_implied_vol_points,
64
+ net_edge_vol_points: options.net_edge_vol_points,
65
+ },
66
+ macro: g.macro || {},
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The deterministic pass over every selected master, before a single agent is spawned.
72
+ *
73
+ * Seats that cannot look are settled here for free. `to_run` is what still needs a model;
74
+ * `declined` is what does not, and both are recorded so the report can show the difference
75
+ * between a method that judged and a method that could not.
76
+ */
77
+ export function planMasters(run, masterIds) {
78
+ const reg = packs();
79
+ const facts = factsFromRun(run);
80
+ const decisions = [];
81
+ const to_run = [];
82
+ const declined = [];
83
+ // No grounding is not the same as a screen that computed nothing. A run that was never
84
+ // grounded has not been measured, so the gate has no facts to judge on and must not read
85
+ // their absence as a failing result -- doing so would silence the whole bench on every
86
+ // run that skips grounding.
87
+ const grounded = Boolean(run?.grounding && typeof run.grounding === "object");
88
+ for (const id of masterIds || []) {
89
+ const pack = reg.get(id);
90
+ if (!pack || !grounded) { to_run.push({ id, engine: "v1_prompt" }); continue; }
91
+ const decision = decide(pack, facts);
92
+ decisions.push({ ...decision, persona_id: id, engine: "v2_method_model", kind: pack.kind });
93
+ if (decision.narratable) to_run.push({ id, engine: "v2_method_model", decision });
94
+ else declined.push({ id, engine: "v2_method_model", decision });
95
+ }
96
+ return { facts, decisions, to_run, declined };
97
+ }
98
+
99
+ /**
100
+ * A declined seat still has to report, because the completeness gate counts every selected
101
+ * master and a seat that is merely skipped would leave the run permanently incomplete.
102
+ *
103
+ * The deterministic pass already produced the verdict, so the opinion is written directly
104
+ * and no agent is spawned. That is the whole saving: the seat is accounted for, the reader
105
+ * sees why it could not look, and nothing was spent to find out.
106
+ */
107
+ export function declinedOpinion(run, id, decision) {
108
+ const unmet = (decision.eligibility?.unmet || [])
109
+ .map((u) => `${u.requirement} [${u.reason}${u.actual !== undefined ? `: ${u.actual}` : ""}]`)
110
+ .join("; ");
111
+ const pack = packs().get(id);
112
+ const name = pack?.display_name?.en || id;
113
+ return {
114
+ master: id,
115
+ symbol: run.symbol,
116
+ as_of: run.as_of,
117
+ stance: "out_of_scope",
118
+ verdict: `${name} cannot evaluate ${run.symbol}: ${decision.reason}`,
119
+ summary: decision.reason === "eligibility"
120
+ ? `This method's entry requirements are not met for ${run.symbol}, so it returns no judgment. Unmet: ${unmet || "n/a"}. Declining is a conclusion, not an abstention, and it was reached deterministically without spending a model call.`
121
+ : `Too little of this method could be computed for ${run.symbol} to be judged rather than sampled (coverage ${Math.round((decision.score?.coverage || 0) * 100)}%), so it returns no judgment.`,
122
+ key_findings: [],
123
+ disagreements: [],
124
+ disqualifiers_triggered: (decision.eligibility?.unmet || []).map((u) => u.requirement),
125
+ what_would_change_my_mind: (decision.eligibility?.unmet || []).map((u) => `${u.requirement} becomes available and is met`),
126
+ source_ids: [],
127
+ confidence: "high",
128
+ engine: "v2_method_model",
129
+ deterministic_stance: "out_of_scope",
130
+ decision_reason: decision.reason,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * What one v2 seat is allowed to read, and the deterministic verdict it must narrate.
136
+ *
137
+ * Handed to the agent so the prompt describes a decision it cannot overturn: the model
138
+ * explains, it does not choose.
139
+ */
140
+ export function briefFor(run, masterId, evidencePackets) {
141
+ const pack = packs().get(masterId);
142
+ if (!pack) return null;
143
+ const facts = factsFromRun(run);
144
+ const decision = decide(pack, facts);
145
+ const slice = sliceFor(pack, { frozen: facts, packets: evidencePackets || run?.packets || [] });
146
+ return {
147
+ persona_id: masterId,
148
+ engine: "v2_method_model",
149
+ kind: pack.kind,
150
+ display_name: pack.display_name,
151
+ decision,
152
+ visible_tasks: slice.packets.map((p) => p.task),
153
+ excluded_tasks: slice.excluded,
154
+ admission_shortfall: pack.admission_shortfall,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * A recorded opinion must not contradict the arithmetic that produced it.
160
+ *
161
+ * The model narrates; if its stance disagrees with the deterministic verdict, the
162
+ * deterministic one stands and the disagreement is recorded rather than resolved silently.
163
+ * This is the same rule that failed once already: a stance that survives normalization
164
+ * without being checked is how ten seats came to read `cautious`.
165
+ */
166
+ export function reconcileOpinion(run, masterId, opinion) {
167
+ const pack = packs().get(masterId);
168
+ // Same rule as the planner: an ungrounded run has no arithmetic to reconcile against, so
169
+ // the narrated stance is all there is and overriding it would be inventing a verdict.
170
+ if (!pack || !run?.grounding) return { opinion, engine: "v1_prompt", overridden: false };
171
+ const decision = decide(pack, factsFromRun(run));
172
+ if (opinion?.stance === decision.stance) {
173
+ return { opinion: { ...opinion, engine: "v2_method_model", deterministic_stance: decision.stance }, engine: "v2_method_model", overridden: false };
174
+ }
175
+ return {
176
+ engine: "v2_method_model",
177
+ overridden: true,
178
+ opinion: {
179
+ ...opinion,
180
+ stance: decision.stance,
181
+ engine: "v2_method_model",
182
+ deterministic_stance: decision.stance,
183
+ narrated_stance: opinion?.stance,
184
+ override_reason: `the deterministic ${pack.persona_id} policy returned ${decision.stance} (${decision.reason}); the narrated stance did not agree and does not govern`,
185
+ },
186
+ };
187
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Loads Persona v2 packs and enforces the admission bar in code.
3
+ *
4
+ * The bar is not a badge an author awards themselves. A pack that declares
5
+ * `kind: "method_model"` on a thin corpus is downgraded to `operator_lens` here, and the
6
+ * shortfall is carried on the pack so the report can say which one the reader is looking
7
+ * at. Letting a manifest self-certify would reproduce, one layer up, the exact failure this
8
+ * release exists to remove: a shape that looks like a judgment, holding none.
9
+ */
10
+
11
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { join } from "node:path";
14
+
15
+ /** Minimum corpus for a pack to carry a person's name. See docs/persona-v2-spec.md. */
16
+ export const ADMISSION_BAR = Object.freeze({
17
+ propositions: 25,
18
+ primary_sources: 5,
19
+ decision_cases: 5,
20
+ failure_cases: 3,
21
+ vetoes: 10,
22
+ counterfactuals: 10,
23
+ });
24
+
25
+ export function defaultKnowledgeDir() {
26
+ return process.env.ALPHACOUNCIL_KNOWLEDGE_DIR
27
+ || fileURLToPath(new URL("../../../knowledge/masters/", import.meta.url));
28
+ }
29
+
30
+ /** Counted from the corpus, never from the manifest's own claim about itself. */
31
+ export function countAdmission(pack) {
32
+ const sources = pack.sources || [];
33
+ return {
34
+ propositions: (pack.doctrine || []).length,
35
+ primary_sources: sources.filter((s) => s.grade === "A" || s.grade === "B").length,
36
+ decision_cases: (pack.decision_cases || []).length,
37
+ failure_cases: (pack.failure_cases || []).length
38
+ || (pack.doctrine || []).filter((d) => (d.counterexamples || []).length).length,
39
+ vetoes: (pack.decision_policy?.vetoes || []).length,
40
+ counterfactuals: (pack.counterfactuals || []).length,
41
+ };
42
+ }
43
+
44
+ export function admissionShortfall(counts) {
45
+ const short = {};
46
+ for (const [key, required] of Object.entries(ADMISSION_BAR)) {
47
+ const have = counts[key] || 0;
48
+ if (have < required) short[key] = { have, required };
49
+ }
50
+ return short;
51
+ }
52
+
53
+ /**
54
+ * A rule may only be defined by a grade A or B source. C supports, D stages, E is rejected.
55
+ * A doctrine entry citing nothing loadable is dropped rather than quietly kept.
56
+ */
57
+ export function validatePack(pack, file = "") {
58
+ const errors = [];
59
+ const fail = (m) => errors.push(`${file}: ${m}`);
60
+ if (pack?.schema_version !== 2) fail(`schema_version must be 2, got ${JSON.stringify(pack?.schema_version)}`);
61
+ if (!/^[a-z0-9_]{2,48}$/.test(pack?.persona_id || "")) fail(`persona_id invalid: ${JSON.stringify(pack?.persona_id)}`);
62
+ if (!pack?.display_name?.en) fail("display_name.en is required");
63
+ if (pack?.display_name?.en && !/model|lens/i.test(pack.display_name.en)) {
64
+ // Naming discipline is a correctness property, not decoration: the product claim is a
65
+ // method, and a display name that reads as a person overstates it on every surface.
66
+ fail(`display_name.en must read as a method ("Buffett Method Model"), got ${JSON.stringify(pack.display_name.en)}`);
67
+ }
68
+ if (!pack?.decision_policy?.eligibility?.requires?.length) fail("decision_policy.eligibility.requires must be non-empty");
69
+ if (!pack?.decision_policy?.stance_bands?.length) fail("decision_policy.stance_bands must be non-empty");
70
+
71
+ const byId = new Map((pack?.sources || []).map((s) => [s.id, s]));
72
+ for (const rule of pack?.doctrine || []) {
73
+ if (!rule.source_ids?.length) { fail(`doctrine ${rule.rule_id}: no source_ids`); continue; }
74
+ const defining = rule.source_ids.filter((id) => ["A", "B"].includes(byId.get(id)?.grade));
75
+ if (!defining.length) fail(`doctrine ${rule.rule_id}: cites no grade A/B source, so it cannot define a rule`);
76
+ for (const id of rule.source_ids) if (!byId.has(id)) fail(`doctrine ${rule.rule_id}: unknown source ${id}`);
77
+ }
78
+ for (const rule of pack?.decision_policy?.scoring?.rules || []) {
79
+ if (!rule.provenance || rule.provenance.length < 8) {
80
+ fail(`scoring rule ${rule.id}: threshold has no provenance; determinism moves the uncertainty into the constant`);
81
+ }
82
+ }
83
+ const leak = pack?.memory_policy?.leak_rule;
84
+ if (leak && leak !== "public_at <= as_of AND memory_created_at <= as_of") {
85
+ fail("memory_policy.leak_rule must carry both clauses or a model reads the future through its own diary");
86
+ }
87
+ return errors;
88
+ }
89
+
90
+ /** Validates, counts, and downgrades kind when the corpus does not support the name. */
91
+ export function loadPack(file) {
92
+ const pack = JSON.parse(readFileSync(file, "utf8"));
93
+ const errors = validatePack(pack, file);
94
+ if (errors.length) throw new Error(errors.join("\n"));
95
+ const counts = countAdmission(pack);
96
+ const shortfall = admissionShortfall(counts);
97
+ const qualifies = Object.keys(shortfall).length === 0;
98
+ return {
99
+ ...pack,
100
+ admission_counted: counts,
101
+ admission_shortfall: shortfall,
102
+ // The manifest's own `kind` is advisory. This one is enforced.
103
+ kind: qualifies ? "method_model" : "operator_lens",
104
+ kind_declared: pack.kind,
105
+ };
106
+ }
107
+
108
+ export function loadPacks({ dir = defaultKnowledgeDir() } = {}) {
109
+ if (!existsSync(dir)) return { packs: [], get: () => undefined };
110
+ const packs = [];
111
+ for (const entry of readdirSync(dir)) {
112
+ const manifest = join(dir, entry, "manifest.json");
113
+ if (statSync(join(dir, entry)).isDirectory() && existsSync(manifest)) packs.push(loadPack(manifest));
114
+ }
115
+ packs.sort((a, b) => a.persona_id.localeCompare(b.persona_id));
116
+ const index = new Map(packs.map((p) => [p.persona_id, p]));
117
+ return { packs, get: (id) => index.get(id) };
118
+ }