fix(consult): project the running Narayana period and pratyantar dates to the model (BUG-1054)

timingKeys now admits current_dasha.md/ad/pd (sign, lord, years,
start_age, end_age), remaining_years and pratyantar_dasha_timeline. Depth
and item caps unchanged. Golden regression over three public AA engine
captures asserts values, not key presence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-27 02:54:11 +08:00
co-authored by Claude Opus 5.5
parent c33701a4bd
commit 75a1844cdf
5 changed files with 198 additions and 6 deletions
+6 -6
View File
@@ -14189,15 +14189,15 @@
## BUG-1054 | 咨询投影把当前 Narayana 段和子运日期裁成空
- 状态:investigating
- 状态:resolved(本地修复 `codex/consult-evidence-card-20260927`,待部署 staging;部署后补门禁 run 号)
- 首次发现 / 最近更新:2026-09-27 / 2026-09-27
- 影响面:`frontend/src/mastra/consultation-workflow.ts` 的 `toModelOutput` / `projectAllowlistedTree`;普通咨询写答案时看到的 timing 卡
- 影响面:`frontend/src/mastra/consultation-workflow.ts` 的 `toModelOutput` / `projectAllowlistedTree`(`timingKeys`);普通咨询写答案时看到的 timing 卡
- 现象:引擎已经算出当前 Narayana 段和 Vimshottari 子运(pratyantar)起止日期,投影给模型的 timing 卡里 `current_dasha` 是空对象,子运日期整段不在。
- 触发条件:有出生分钟的本命咨询走 `toModelOutput`。2026-09-27 数据卡调研用 3 张公开 AA 盘 × 10 种问法,30/30 都是这个形状。
- 根因:timing 投影只保留 allowlist 里的键。Narayana 当前段写在 `current_dasha.md` / `ad` / `pd` 下,`md` 不在 allowlist 里,于是对象被留成空。子运日期的键是 `pratyantar_dasha_timeline`,也不在 allowlist 里,整段被丢掉。大运和子运(antardasha)的日期键在 allowlist 里,所以那两段还在。
- 修复:未修。本单是调研,不改投影。
- 验证:调研脚本对 30 次投影做了结构化比对:上升、月亮星座与宫位、当前大运起止、D12 落点与引擎一致;Narayana 当前星座和子运起止在卡里是空。见 `docs/research/consult_evidence_card_research_2026_09_27.md`。
- 防复发:实现单补投影时,要锁住「引擎当前 Narayana 星座和子运起止日期原样出现在模型 timing 卡里」,不能只断言 `narayana_dasha` 键存在。
- 修复:`timingKeys` 放行 `md` / `ad` / `pd`、它们的嵌套标量 `years` / `start_age` / `end_age`、`remaining_years` 与 `pratyantar_dasha_timeline`;其余白名单、深度上限(4)与条数上限(24)不变。
- 验证:新增 `frontend/tests/consult-projection-timing-20260927.test.ts`(4 条),fixture 是真实引擎对 3 张公开 AA 盘的 family 路由输出(`frontend/tests/fixtures/consult-evidence-card-golden.json`,由 `scripts/research/capture_consult_evidence_card_golden.py` 生成,只按键裁剪、不改值)。逐盘断言引擎的 Narayana 当前 md / ad / pd 的星座、主星、年数、起止年龄与 `remaining_years`,以及 pratyantar 当前段与下一段的主星和起止日期,原样出现在模型可见的 timing 卡里;大运、子运日期不变。修复前 2 / 4 条失败。
- 防复发:timing 卡的回归断言值而不是键;数据卡(同一分支 T3)从这张投影复制当前 Narayana 段与 PD 起止,卡的逐字测试再锁一层。
- 相关记录:BUG-287(同一条 allowlist 曾经把分盘和审计表整段挡住;这次是嵌套键还没放行,不是「没算」复发)
- 复发自:无
- 修复版本:无
- 修复版本:`codex/consult-evidence-card-20260927`(本地提交,未推送)
@@ -334,6 +334,12 @@ const timingKeys = new Set([
"charadasha", "transits", "sadesati", "triggers", "triggercount", "searchperiod", "window", "sequence", "durationyears",
"fromage", "toage", "phase", "phasename", "moonsign", "saturnsign", "intensity", "active",
"date", "target", "kind", "orb", "boundary", "claimboundary",
// BUG-1054: the engine writes the running Narayana period under
// `current_dasha.md` / `ad` / `pd` (sign, lord, years, start_age, end_age)
// and the Vimshottari pratyantar window under `pratyantar_dasha_timeline`.
// Without these keys the projection kept `current_dasha` as an empty object
// and dropped the pratyantar dates, although the engine had both.
"md", "ad", "pd", "years", "startage", "endage", "remainingyears", "pratyantardashatimeline",
]);
const validationKeys = new Set([
@@ -0,0 +1,87 @@
// BUG-1054: the model-visible timing projection dropped the running Narayana
// period (`current_dasha.md/ad/pd`) and the Vimshottari pratyantar window
// (`pratyantar_dasha_timeline`), although the engine computed both.
//
// The fixture is a real engine capture (AGENTS §7.4) of three public AA charts:
// scripts/research/capture_consult_evidence_card_golden.py. Expected values are
// read straight from the engine payload, and the assertion is on values, not on
// the key being present.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
type Json = Record<string, unknown>;
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: Array<{ id: string; workflow: Json }> };
function dig(value: unknown, ...keys: string[]): unknown {
let current = value;
for (const key of keys) {
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
current = (current as Json)[key];
}
return current;
}
function timingCard(workflow: Json) {
const parsed = consultationWorkflowResponseSchema.parse(structuredClone(workflow));
const packet = toModelOutput(toAgentConsultationContext(parsed));
const card = packet.claim_cards.find((item) => item.category === "timing");
assert.ok(card, "the timing claim card is present");
return card.evidence as Json;
}
test("the fixture carries the engine values the projection used to drop", () => {
assert.equal(golden.charts.length, 3);
for (const chart of golden.charts) {
const modules = dig(chart.workflow, "chart", "modules");
assert.equal(typeof dig(modules, "narayana_dasha", "current_dasha", "md", "sign"), "string", chart.id);
assert.match(String(dig(modules, "dasha_sub_periods", "pratyantar_dasha_timeline", "current", "start")), /^\d{4}-\d{2}-\d{2}$/, chart.id);
}
});
test("the running Narayana period reaches the model timing card with the engine's own values", () => {
for (const chart of golden.charts) {
const engine = dig(chart.workflow, "chart", "modules", "narayana_dasha", "current_dasha") as Json;
const projected = dig(timingCard(chart.workflow), "narayana_dasha", "current_dasha") as Json;
assert.ok(projected && Object.keys(projected).length > 0, `${chart.id}: current_dasha is no longer empty`);
for (const level of ["md", "ad", "pd"] as const) {
for (const key of ["sign", "lord", "years", "start_age", "end_age"] as const) {
assert.equal(dig(projected, level, key), dig(engine, level, key), `${chart.id} ${level}.${key}`);
}
}
assert.equal(projected.remaining_years, engine.remaining_years, `${chart.id} remaining_years`);
}
});
test("the Vimshottari pratyantar window reaches the model timing card with the engine's own dates", () => {
for (const chart of golden.charts) {
const engine = dig(chart.workflow, "chart", "modules", "dasha_sub_periods", "pratyantar_dasha_timeline") as Json;
const projected = dig(timingCard(chart.workflow), "dasha_sub_periods", "pratyantar_dasha_timeline") as Json;
for (const phase of ["current", "next"] as const) {
for (const key of ["lord", "start", "end"] as const) {
assert.equal(dig(projected, phase, key), dig(engine, phase, key), `${chart.id} pratyantar ${phase}.${key}`);
}
}
}
});
test("the mahadasha and antardasha dates the projection already carried are unchanged", () => {
for (const chart of golden.charts) {
const engine = dig(chart.workflow, "chart", "modules", "dasha_sub_periods", "current") as Json;
const projected = dig(timingCard(chart.workflow), "dasha_sub_periods", "current") as Json;
for (const level of ["mahadasha", "antardasha"] as const) {
for (const key of ["lord", "start", "end"] as const) {
assert.equal(dig(projected, level, key), dig(engine, level, key), `${chart.id} ${level}.${key}`);
}
}
}
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,98 @@
"""Capture real engine consultation responses for the evidence-card tests.
Three public AA charts (Steve Jobs, Barack Obama, Elizabeth Taylor), the
research reference date, raman ayanamsa, mean nodes, family route. External
VedAstro is not called (same stand-in as the research runner). The response is
trimmed by key only: every kept value is the engine's own value, unchanged.
PYTHONHASHSEED=0 python scripts/research/capture_consult_evidence_card_golden.py
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "scripts"), str(ROOT / "scripts" / "research")]
from consult_evidence_card_lib import ( # noqa: E402
AYANAMSA,
NODE_MODE,
QUESTIONS,
REFERENCE_DATE,
load_public_charts,
)
import consult_evidence_card_run as runner # noqa: E402
OUT = ROOT / "frontend" / "tests" / "fixtures" / "consult-evidence-card-golden.json"
TOP_KEEP = (
"success", "question", "routing", "consumer_context", "thematic_report",
"birth_time_sensitivity", "reference_transparency", "candidate_range",
"range_boundary_contexts",
)
RECTIFICATION_KEEP = ("effective_accuracy", "lagna_boundary", "summary", "enabled_vargas")
CHART_KEEP = ("success", "birth", "ascendant", "planets", "houses", "shadbala", "dasha", "yogas")
MODULE_KEEP = (
"varga_spectrum", "shadbala", "arudha_padas", "jaimini", "narayana_dasha",
"ashtakavarga", "dasha_sub_periods", "kp_cusps", "gulika",
"functional_benefic_malefic", "kakshya", "yogas", "chara_dasha", "transits",
)
NARAYANA_KEEP = ("lagna_sign", "current_dasha", "current_year", "current_age", "mahadasha_sequence")
def _pick(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
return {key: value[key] for key in keys if key in value}
def trim(workflow: dict[str, Any]) -> dict[str, Any]:
out = _pick(workflow, TOP_KEEP)
out["rectification"] = _pick(workflow.get("rectification") or {}, RECTIFICATION_KEEP)
chart = workflow.get("chart") or {}
kept_chart = _pick(chart, CHART_KEEP)
modules = _pick(chart.get("modules") or {}, MODULE_KEEP)
if isinstance(modules.get("narayana_dasha"), dict):
modules["narayana_dasha"] = _pick(modules["narayana_dasha"], NARAYANA_KEEP)
kept_chart["modules"] = modules
out["chart"] = kept_chart
return out
def main() -> int:
if os.environ.get("PYTHONHASHSEED") != "0":
raise SystemExit("Set PYTHONHASHSEED=0 before starting this process (ERR-111).")
runner._block_external_vedastro()
question = next(item for item in QUESTIONS if item["id"] == "family")
charts = []
for chart in load_public_charts(ROOT):
workflow = runner._run_workflow(runner._workflow_body(chart, question))
charts.append({
"id": chart["id"],
"label": chart["label"],
"source": chart["source"],
"case_id": chart["case_id"],
"rodden_rating": chart["rodden_rating"],
"workflow": trim(workflow),
})
payload = {
"source": "scripts/research/capture_consult_evidence_card_golden.py",
"note": "Real engine consultation_workflow responses for three public AA charts, trimmed by key only.",
"reference_date": REFERENCE_DATE,
"ayanamsa": AYANAMSA,
"node_mode": NODE_MODE,
"route": question["domain"],
"question": question["question"],
"external_vedastro": "not_called",
"charts": charts,
}
OUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main())