feat: enforce commercial rectification evidence contracts

* feat: enforce precise timing output contract

* fix: recognize package imports in fragment audit

* test: make workflow stream contract formatting-independent

* fix: preserve VedAstro evidence across async workflows

* feat: enforce commercial technique truth contract

* feat: add rectification technique receipt

* feat: extend rectification event evidence

* feat: score rectification arudha evidence

* feat: gate high rigor rectification confirmation

* feat: add controlled transit to rectification

* feat: include d11 in rectification finance scoring

* feat: add ashtakavarga rectification auxiliary

* feat: show rectification technique receipt

* feat: use verified shadbala components in rectification

* fix: trace transitive script references in fragment audit

* feat: run request-level rectification parity packet
This commit is contained in:
732642856
2026-07-19 22:23:29 +08:00
committed by GitHub
parent d9b07802a5
commit 4ceb3a5157
42 changed files with 995 additions and 85 deletions
+8 -7
View File
@@ -1,7 +1,7 @@
---
name: jyotish-vedic-astrology
version: 6.9.14
description: "印度占星(Jyotish业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→精确推运应期输出。35种Dasha、405+Yoga规则、KP系统、Prashna卜卦、16因子合盘、Remedies补救、Sahams部分覆盖、Sudarshana三参考点、PMC完整检测、Tajika年度星盘、案例验证+误区纠正。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga。GitHub: https://github.com/732642856/yinduzhanxing"
description: "印度占星(Jyotish业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→受合同约束的推运方向输出。Dasha、Yoga、分盘、合盘与补救建议按计算回执和能力状态交付;精确应期及受限技法不会作为确定性商业结论。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga。GitHub: https://github.com/732642856/yinduzhanxing"
---
# 印度占星专业解盘与推运系统
@@ -14,6 +14,7 @@ description: "印度占星(Jyotish)专业解盘与推运系统。核心能
> **严格路由**`references/strict-workflow-router.md`(涉及事业/婚恋/财务/应期/技法验证时必须优先读取)
> **机器注册表**`references/technique_registry.json` + `scripts/audit_capabilities.py`
> **能力真相边界**:回答前必须参考 `references/oracle/skill_truth_overlay_2026_07_19.json` 与 `references/oracle/effective_skill_capability_view_2026_07_19.json`;不得直接把 `references/technique_registry.json` 的旧 `covered` 当作完整闭环。
> **商业声明合同**`references/oracle/commercial_skill_truth_overlay.v1.json`。`reference_only`、`partial`、`blocked`、`research_only_blocked`、`partial_registry_only` 不得进入确定性结论。
> **文章级细节模板**`references/interpretation_template_registry.json` + `scripts/validate_interpretation_templates.py`
### Skill truth overlay 硬边界
@@ -36,7 +37,7 @@ KP/Muhurta/Gochara/Sahams/Sphuta/Tajika等高阶分支必须按 skill truth over
| Sudarshana | Asc/Moon/Sun 三参考点盘 + 宫位收敛分析 |
| Shadbala | absolute Rupa 分量求和;内部不变量通过,外部绝对值 oracle 扩充中 |
| Ashtakavarga | BAV+SAV+PAV(展开式)+Sodhita(净化式) |
| KP系统 | Sublord+Subsublord+ABCD Significator(输出可用,细粒度传统口径仍以实测与案例闭环为准) |
| KP系统 | `reference_only`;不得作为主结论或精确应期依据 |
| 合盘 | 16因子36分制(Ashtakoot+Kuta |
| 补救 | 5类(宝石/咒语/捐赠/斋戒/Dosha专项) |
| 自动化测试 | pytest/quality gate 分层守门;以当前仓库质量门输出为准 |
@@ -58,7 +59,7 @@ KP/Muhurta/Gochara/Sahams/Sphuta/Tajika等高阶分支必须按 skill truth over
## ⚠️ 核心定位
**三种输入 → 严谨解盘 → 精确推运应期输出**
**三种输入 → 严谨解盘 → 受合同约束的推运方向输出**
| 路径 | 用户输入 | AI行为 |
|------|---------|--------|
@@ -261,7 +262,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r
- Dasha-only 外部证据当前目标集已闭环:`dasha_external_oracle_evidence_validation.valid_dasha_packets: 3/3`Steve Jobs / Lahiri、synthetic Lahiri template 与 1800 Delhi historical epoch 的 Vimshottari 起始边界来自 PyJHora 4.8.7 隔离黑盒 stdout artifact。
- 全局 Dasha/Shadbala Calibration Status 仍未完成:`external_oracle_evidence_validation.valid_packets: 4``ready_for_calibration: 4`;Shadbala 外部绝对值当前目标集已通过 4/4,Raman 扩展样本与非 Dasha 靶点尚未封顶。
- 历史 UI 静态门禁仍保留旧提示 `ready_for_calibration: 0` 作为“不得过度宣称”的保守文案;实际进度必须以当前 `oracle_collection_queue.py` / `oracle_evidence_validator.py` 输出为准。
- Tajika/Sahams 年运外部样本已开始闭环:`tajika_sahams_annual_benchmark_dashboard.ready_for_calibration: 1/5`Steve Jobs 1984 Varshaphala/Lahiri 的 solar return、Varsha Lagna、Muntha、Year Lord、Mudda Dasha 首主、三项 Sahams 与 Tajika Yogas 已由 PyJHora 4.8.7 隔离黑盒 artifact 验证,下一优先级为 Einstein 1905。仍不得声称 Tajika/Sahams 年运体系已全局封顶
- Tajika/Sahams 年运资料仅保留为研发基准记录;商业声明合同将 Sahams 设为 `blocked`、Tajika 设为 `partial`,两者均不得进入确定性结论或精确应期
- D1/D9/SAV 高可信;Dasha 精细日期可引用已验证 Dasha-only 样本的局部进度,但不得把全部大运边界、Shadbala 绝对值或全局精度说成已完成外部校准。
- 不得把大运起点或 Shadbala 绝对值说成已完成外部校准;涉及具体日期/绝对力量值时,必须同时报告 `Dasha/Shadbala Calibration Status``external_oracle_evidence_validation``production_tuning_allowed: false` 边界。
- `production_tuning_allowed: false` 前,禁止为了贴合单份 PDF、单个 JHora 截图或本仓库本地输出而改生产常数。
@@ -334,7 +335,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r
| **关系占星** | Koota 36分、Mahendra/Stree Deergha/Vedha/Rajju、D9伴侣、DK、Mangal Dosha、Papasamya、配偶六层确认 | `spouse-multi-layer-methodology.md` `darakaraka-complete-guide.md` `relationship-astrology-guide.md` |
| **出生时间矫正** | 八大方法、自动化流程、验证报告、分盘调用决策树 | `birth-time-rectification-advanced.md` `birth-time-rectification-decision-tree.md` |
| **PDF读取** | JH/PL PDF全量提取、完整性门、交叉校验 | `pdf-chart-reading-guide.md` `data-bridge-mapping.md` |
| **Prashna问事** | 十步断卦、AL、SphutaSahams、失物查询 | `prashna-complete-guide.md` `single-event-inquiry-protocol.md` |
| **Prashna问事** | 十步断卦、AL、失物查询;Sphuta/Sahams 不属于可交付商业结论 | `prashna-complete-guide.md` `single-event-inquiry-protocol.md` |
| **多元技法** | Yogi/Ava Yogi、Tithi Lord、Rashi Tulya Navamsa、BCP、Bhrigu Pada、Pancha Pakshi、Tara Bala、Deha/Jeeva、Moolatrikona、Shodasavarga/Vimsopaka、Ashwini/Abhijit/Ketu星宿专题(需保留成熟度边界) | `yogi-avayogi-system.md` `yogi-asc-tight-orb-wealth-freeze-guide.md` `tithi-lord-relationship-system.md` `tithi-lord-freeze-execution-guide.md` `rtn-high-order-d9-freeze-execution-guide.md` `bhrigu-pada-all-event-freeze-execution-guide.md` `ashwini-abhijit-ketu-nakshatra-freeze-guide.md` `bhrigu-chakra-paddhati.md` `shodasavarga-complete-guide.md` `planetary-dignity-complete-reference.md` `alternative-dasha-systems.md` |
| **精准方法论** | PACDARES框架、九层复合方法、L3矛盾检查、三级置信度 | `precision-reading-methodology.md` |
| **解读质检** | 真实解读结构质检、参数冻结、分盘强制展开、oracle 诚信边界 | `real-reading-quality-checklist.md` |
@@ -545,7 +546,7 @@ $PYTHON $SCRIPT <子命令> [参数]
**PACDARES框架**:P位置→A相位→C合相→D财富Yoga→A灾厄Yoga→R皇家Yoga→E互换→S特殊
**九层复合方法**L1 PACDARES → L2 分盘 → L3 矛盾检查(关键) → L4 Vimshottari → L5 AV+Transit → L6 条件Dasha → L7 Jaimini → L8 其他Jaimini → L9 Tajika
**九层复合方法(研究参考)**L1 PACDARES → L2 分盘 → L3 矛盾检查(关键) → L4 Vimshottari → L5 AV+Transit → L6 条件Dasha → L7 Jaimini → L8 其他Jaimini → L9 Tajika;商业输出须遵守声明合同,Tajika 不得形成确定性结论。
**三级置信度**:✅[A]已验证 / ⭐[B]强推断(3+维度) / ⚡[C]假设(单一维度)
@@ -583,7 +584,7 @@ $PYTHON $SCRIPT <子命令> [参数]
- [ ] Dasha推运(大运+小运+Pratyantar
- [ ] Dasa Convergence五系统交叉验证
- [ ] Jaimini分析(Karaka/KarakamshaChara Dasha 已通过 KN Rao Method benchmark,剩余共主仲裁差异需声明)
- [ ] KP系统分析(Significator+Sub-Lord
- [ ] KP系统背景参考(不得作为商业主结论或精确应期依据
- [ ] Transit分析(多参考点强制)
- [ ] **Transit Actionable Output**(时间段+行动+置信度+案例检索)
- [ ] 分盘验证
+5
View File
@@ -124,6 +124,11 @@ Prevention: add a new domain only after its public cases satisfy the same source
## Fragment Sweep Command Set
## ERR-084 | Pre-work fragment test assumes zero candidates despite current audited candidates | active 2026-07-19
`scripts/pre_work_check.py` reports `fragment_audit.candidate_count=2`, while `tests/test_preflight_fragment_scan.py` requires exactly zero. The pre-work command therefore cannot be reported green until the two candidates are classified or the test is updated to validate the reviewed state rather than a hard-coded count.
Prevention: retain candidate identity and classification in the sweep artifact; do not mask candidates or weaken the pre-work result.
Use split scans, not one unbounded full-home command:
```bash
+83 -21
View File
@@ -19,6 +19,7 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { guardPreciseTimingOutput } from "@/lib/timing-output-guard";
import { z } from "zod";
export const runtime = "nodejs";
@@ -29,10 +30,15 @@ const chatRequestSchema = consultationInputSchema.extend({
modelId: z.string().trim().min(1).max(64),
entrypoint: consultationEntrypointSchema.optional(),
name: z.string().trim().max(80).optional().default(""),
history: z.array(z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
})).max(20).default([]),
history: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
}),
)
.max(20)
.default([]),
});
function currentTimeContext(now = new Date()) {
@@ -67,10 +73,15 @@ async function recordModelUsage(
.eq("transaction_type", "reserve")
.eq("request_id", requestId);
if (error) console.warn(`[billing] unable to record model usage request=${requestId} model=${modelId}`);
if (error)
console.warn(
`[billing] unable to record model usage request=${requestId} model=${modelId}`,
);
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.warn(`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`);
console.warn(
`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`,
);
}
}
@@ -87,7 +98,10 @@ export async function POST(request: Request) {
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
@@ -95,7 +109,9 @@ export async function POST(request: Request) {
);
}
const parsed = chatRequestSchema.safeParse(await request.json().catch(() => null));
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
@@ -111,7 +127,11 @@ export async function POST(request: Request) {
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
{ error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。" },
{
error: "无法处理该请求",
message:
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
},
{ status: 400 },
);
}
@@ -130,11 +150,19 @@ export async function POST(request: Request) {
modelSelection = await reserveConsultationModel(
parsed.data.modelId,
resolveLanguageModel,
() => runCreditRpc(accounting, "begin_consultation_credit", userId, requestId),
() =>
runCreditRpc(
accounting,
"begin_consultation_credit",
userId,
requestId,
),
);
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[billing] reservation failed request=${requestId} reason=${reason}`);
console.error(
`[billing] reservation failed request=${requestId} reason=${reason}`,
);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
@@ -143,7 +171,10 @@ export async function POST(request: Request) {
if (modelSelection.status === "unavailable") {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{
error: "模型暂不可用",
message: "请选择其他模型后重新发送,本次不会扣除点数。",
},
{ status: 409 },
);
}
@@ -156,7 +187,9 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.error_code || "请稍后重试。",
message: insufficient
? "请先兑换咨询点数后再继续。"
: reserveResult.error_code || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
@@ -164,16 +197,29 @@ export async function POST(request: Request) {
async function cancel() {
try {
await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId);
await runCreditRpc(
accounting,
"cancel_consultation_credit",
userId,
requestId,
);
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[billing] cancellation failed request=${requestId} reason=${reason}`);
console.error(
`[billing] cancellation failed request=${requestId} reason=${reason}`,
);
}
}
async function complete() {
const result = await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId);
if (!result.success) throw new CreditRpcError(result.error_code || "completion_rejected");
const result = await runCreditRpc(
accounting,
"complete_consultation_credit",
userId,
requestId,
);
if (!result.success)
throw new CreditRpcError(result.error_code || "completion_rejected");
}
let settlement: Promise<void> | null = null;
@@ -208,15 +254,27 @@ export async function POST(request: Request) {
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(accounting, userId, requestId, modelSelection.usageModelId, result.totalUsage);
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
transformText:
workflowReceipt.preciseTiming === "blocked"
? guardPreciseTimingOutput
: undefined,
mode: "mastra",
requestId,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
},
@@ -227,12 +285,16 @@ export async function POST(request: Request) {
} catch (error) {
await cancel();
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`);
console.error(
`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`,
);
return NextResponse.json(
{
error: "暂时无法生成解读",
message: "咨询服务暂时不可用,请稍后再试。",
recovery: languageModelConfigurationMessage() ? "当前没有可用的咨询模型,请联系管理员。" : "稍后重试,或换一个模型继续。",
recovery: languageModelConfigurationMessage()
? "当前没有可用的咨询模型,请联系管理员。"
: "稍后重试,或换一个模型继续。",
},
{ status: 503 },
);
+1
View File
@@ -475,6 +475,7 @@ button:disabled { cursor: default; opacity: .45; }
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
.message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; }
.message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-muted); font-size: var(--type-body-sm); line-height: 1.5; }
.message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); }
.message-markdown h2, .message-markdown h3 { margin: 24px 0 10px; color: var(--color-ink); font-family: var(--font-display); font-weight: 400; letter-spacing: -.3px; }
.message-markdown h2 { font-size: var(--type-display-sm); }
+3 -1
View File
@@ -2119,6 +2119,7 @@ export default function Home() {
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
}
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
const techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown";
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -2147,7 +2148,7 @@ export default function Home() {
const completedSession: ChatSession = {
...userSession,
title: currentSession.messages.length === 0 && reply.title ? reply.title : userSession.title,
messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions }],
messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions, techniqueTruth }],
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
@@ -2511,6 +2512,7 @@ export default function Home() {
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText).map((message) => (
<ChatMessageRow key={message.renderKey} message={message} />
))}
))}
{activeError && <p className="error-message">{activeError}</p>}
<div ref={conversationEnd} />
</div>
@@ -20,6 +20,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
const action = journey.nextAction;
const dynamic = journey.journeyProtocol === "dynamic-choice-v2";
const terminalPath = guidedTerminalPath(journey);
if (result?.eventCount === 0) {
return (
<div className="birth-time-candidate-result" aria-live="polite">
<p className="birth-time-assessment-unavailable"></p>
<p className="birth-time-evidence-boundary"></p>
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}></button>}
</div>
);
}
if (!result && action.kind === "present_low_result") {
return (
<div className="birth-time-candidate-result" aria-live="polite">
@@ -30,6 +39,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
}
if (!result) return null;
const winner = result.winningSegment;
const receipt = result.techniqueReceipt;
return (
<div className="birth-time-candidate-result" aria-live="polite">
@@ -48,6 +58,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
<p className="birth-time-assessment-unavailable"></p>
)}
<p className="birth-time-evidence-boundary"></p>
{receipt && (
<details className="birth-time-evidence-receipt">
<summary></summary>
<p>{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
<p>{receipt.auxiliaryLayers.join("、") || "无"}</p>
<p>{receipt.missingLayers.join("、") || "无"}</p>
{receipt.hardBlockers.length > 0 && <p>{receipt.hardBlockers.join("、")}</p>}
</details>
)}
{action.kind === "present_low_result" && (
<div className="birth-time-candidate-terminal" role="status">
@@ -10,6 +10,7 @@ const domainLabels = {
relocation: "搬迁与长期居住地",
relationship: "重要关系",
career: "工作与身份变化",
finance: "收入、资产或资源变化",
health_pressure: "健康或生活压力",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
+11
View File
@@ -83,6 +83,16 @@ const candidateEvidenceSchema = z.object({
points: z.number(),
}).strict().readonly();
const rectificationTechniqueReceiptSchema = z.object({
calculationStatus: z.enum(["not_started", "evaluated"]),
usedDivisionalCharts: z.array(z.string()),
usedArudha: z.array(z.string()),
dashaTracks: z.array(z.string()),
missingLayers: z.array(z.string()),
auxiliaryLayers: z.array(z.string()).default([]),
hardBlockers: z.array(z.string()),
}).strict().readonly();
export const candidateResultSchema = z.object({
resultId: z.string().uuid(),
confidence: z.enum(["low", "medium", "high"]),
@@ -101,6 +111,7 @@ export const candidateResultSchema = z.object({
reasons: z.array(z.string().trim().min(1)),
evidence: z.array(candidateEvidenceSchema),
algorithmVersion: z.string().trim().min(1),
techniqueReceipt: rectificationTechniqueReceiptSchema.optional(),
}).strict().readonly().superRefine((value, context) => {
const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value);
if (value.confidence === "high" && value.eventCount < 4) {
+2 -1
View File
@@ -47,7 +47,7 @@ const guideQuestionOutputSchema = z.object({
}).strict().readonly();
export const evidenceDraftModelOutputSchema = z.object({
domain: z.enum(["education", "relocation", "relationship", "career", "health_pressure"]),
domain: z.enum(["education", "relocation", "relationship", "career", "finance", "health_pressure"]),
precision: z.enum(["year", "month", "day"]).nullable(),
date: z.string().trim().min(1).max(10).nullable(),
}).strict().readonly();
@@ -98,6 +98,7 @@ const subjectByDomain = {
relocation: "一次搬家、离乡或长期居住地变化",
relationship: "一次关系进入、关系结束或关系明显转变",
career: "一次明显的工作、职业方向或身份变化",
finance: "一次收入、资产、负债或资源渠道的明显变化",
health_pressure: "一次持续的健康压力或生活压力变化",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
@@ -12,7 +12,7 @@ type ProposeDraft = (
caseId: string,
actionId: string,
expectedVersion: number,
proposal: { readonly domain: "education" | "relocation" | "relationship" | "career" | "health_pressure"; readonly precision: EvidenceDatePrecision; readonly date: string },
proposal: { readonly domain: "education" | "relocation" | "relationship" | "career" | "finance" | "health_pressure"; readonly precision: EvidenceDatePrecision; readonly date: string },
) => Promise<VersionedJourneyResponse>;
type DraftRevision = {
readonly userId: string;
@@ -54,6 +54,7 @@ const sampleSchema = z.object({
ascendant: signSchema,
varga_lagna: z.object({
D4: signSchema,
D2: signSchema,
D9: signSchema,
D10: signSchema,
D24: signSchema,
@@ -81,6 +82,7 @@ const eventDomainSchema = z.enum([
"relocation",
"relationship",
"career",
"finance",
"health_pressure",
]);
const candidateResultApiSchema = z.object({
@@ -107,6 +109,15 @@ const candidateResultApiSchema = z.object({
points: z.number(),
})),
algorithm_version: z.string(),
technique_contract: z.object({
calculation_status: z.enum(["not_started", "evaluated"]),
used_divisional_charts: z.array(z.string()),
used_arudha: z.array(z.string()),
dasha_tracks: z.array(z.string()),
missing_layers: z.array(z.string()),
auxiliary_layers: z.array(z.string()).default([]),
hard_blockers: z.array(z.string()),
}).optional(),
}).passthrough();
class UnexpectedProfileSourceError extends Error {
@@ -161,6 +172,7 @@ export function parseRectificationQuestionnaire(value: unknown): RectificationQu
questions: parsed.questions.map(normalizeQuestion),
samples: parsed.candidate_scan.samples.map((sample) => ({
ascendantSign: sample.ascendant?.sign ?? null,
...(sample.varga_lagna?.D2?.sign ? { d2Sign: sample.varga_lagna.D2.sign } : {}),
d4Sign: sample.varga_lagna?.D4?.sign ?? null,
d9Sign: sample.varga_lagna?.D9?.sign ?? null,
d10Sign: sample.varga_lagna?.D10?.sign ?? null,
@@ -226,5 +238,14 @@ function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>):
points: item.points,
})),
algorithmVersion: parsed.algorithm_version,
...(parsed.technique_contract ? { techniqueReceipt: {
calculationStatus: parsed.technique_contract.calculation_status,
usedDivisionalCharts: parsed.technique_contract.used_divisional_charts,
usedArudha: parsed.technique_contract.used_arudha,
dashaTracks: parsed.technique_contract.dasha_tracks,
missingLayers: parsed.technique_contract.missing_layers,
auxiliaryLayers: parsed.technique_contract.auxiliary_layers,
hardBlockers: parsed.technique_contract.hard_blockers,
} } : {}),
});
}
@@ -11,7 +11,7 @@ const rectificationQuestionSchema = z.object({
}).strict().readonly();
const candidateVargaSampleSchema = z.object({
ascendantSign: z.string().nullable(), d4Sign: z.string().nullable(), d9Sign: z.string().nullable(),
d10Sign: z.string().nullable(), d24Sign: z.string().nullable(), d30Sign: z.string().nullable(),
d2Sign: z.string().nullable().optional(), d10Sign: z.string().nullable(), d24Sign: z.string().nullable(), d30Sign: z.string().nullable(),
}).strict().readonly();
export const questionnaireSchema = z.object({
questions: z.array(rectificationQuestionSchema).readonly(), samples: z.array(candidateVargaSampleSchema).readonly(), raw: z.record(z.unknown()).readonly(),
@@ -10,7 +10,7 @@ const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
export const questionSpecSchema: z.ZodType<QuestionSpec> = z.object({
questionId: z.string().trim().min(1),
phase: z.enum(["baseline", "adaptive"]),
domain: z.enum(["education", "relocation", "relationship", "career", "health_pressure"]),
domain: z.enum(["education", "relocation", "relationship", "career", "finance", "health_pressure"]),
requestedPrecision: z.array(z.enum(["day", "month", "year"])).min(1),
allowUnknown: z.literal(true),
purposeCode: z.string().trim().min(1),
@@ -1,5 +1,5 @@
export const evidenceDomains = [
"education", "relocation", "relationship", "career", "health_pressure",
"education", "relocation", "relationship", "career", "finance", "health_pressure",
] as const;
export type EvidenceDomain = (typeof evidenceDomains)[number];
@@ -7,6 +7,7 @@ export type EvidenceQuestionPhase = "baseline" | "adaptive";
export type EvidenceDatePrecision = "day" | "month" | "year";
export type CandidateVargaSample = {
readonly d2Sign?: string | null;
readonly d4Sign: string | null;
readonly d9Sign: string | null;
readonly d10Sign: string | null;
@@ -37,6 +38,7 @@ const layerByDomain = {
relocation: "d4Sign",
relationship: "d9Sign",
career: "d10Sign",
finance: "d2Sign",
health_pressure: "d30Sign",
} as const;
@@ -57,7 +59,8 @@ function questionSpecFor(
}
export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null {
const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain));
const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain)
&& (domain !== "finance" || (input.phase === "adaptive" && input.coveredDomains.length >= 2)));
const ranked = available.map((domain) => ({
domain,
split: new Set(input.samples.map((sample) => sample[layerByDomain[domain]]).filter(Boolean)).size,
+31 -11
View File
@@ -8,6 +8,7 @@ type StreamTextResponseOptions = StreamHooks & {
readonly mode: "engine" | "mastra";
readonly requestId: string;
readonly headers?: Record<string, string>;
readonly transformText?: (text: string) => string;
};
export function streamTextResponse(
@@ -16,27 +17,46 @@ export function streamTextResponse(
) {
const iterator = stream[Symbol.asyncIterator]();
const encoder = new TextEncoder();
// Keep a full natural-language clause unflushed so a later stream chunk cannot
// turn an allowed prefix into a disallowed timing or guaranteed conclusion.
const guardTailLength = options.transformText ? 1024 : 0;
let pending = "";
let settled = false;
let emitted = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await iterator.next();
if (done) {
settled = true;
if (!emitted) {
const error = new Error("empty_stream");
await options.onError?.(error, false);
controller.error(error);
while (true) {
const { done, value } = await iterator.next();
if (done) {
if (pending)
controller.enqueue(
encoder.encode(options.transformText?.(pending) ?? pending),
);
settled = true;
if (!emitted) {
const error = new Error("empty_stream");
await options.onError?.(error, false);
controller.error(error);
return;
}
await options.onComplete?.();
controller.close();
return;
}
await options.onComplete?.();
controller.close();
if (/\S/.test(value)) emitted = true;
pending += value;
if (pending.length <= guardTailLength) continue;
const stableLength = pending.length - guardTailLength;
const stable = pending.slice(0, stableLength);
pending = pending.slice(stableLength);
controller.enqueue(
encoder.encode(options.transformText?.(stable) ?? stable),
);
return;
}
if (/\S/.test(value)) emitted = true;
controller.enqueue(encoder.encode(value));
} catch (error) {
if (!settled) {
settled = true;
+26
View File
@@ -0,0 +1,26 @@
const exactTimingPatterns = [
/\b(?:19|20)\d{2}[-/.年]\s?\d{1,2}(?:[-/.月]\s?\d{1,2}(?:日|号)?)?\b/g,
/\b(?:19|20)\d{2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?:\s+\d{1,2})?\b/gi,
/\b\d{1,2}\/\d{1,2}\/(?:\d{2}|\d{4})\b/g,
/(?:今年|明年|后年|(?:19|20)\d{2}年)?\s*\d{1,2}月(?:\s*\d{1,2}[日号])?/g,
/\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s*(?:19|20)\d{2})?\b/gi,
];
const guaranteeConclusionPatterns = [
/(?:^|[。!?.!?\n])[^。!?.!?\n]*(?:一定|必然|保证|肯定|必定|注定|绝对)(?:会|能|将|发生|成功|结婚|复合|怀孕|发财|升职|得到|实现|出现)[^。!?.!?\n]*/g,
/(?:^|[.?!\n])[^.?!\n]*\b(?:will definitely|guaranteed? to|certain to|without doubt)\b[^.?!\n]*/gi,
];
/** Removes claims the evidence contract does not permit the model to make. */
export function guardPreciseTimingOutput(text: string) {
let guarded = text;
for (const pattern of exactTimingPatterns)
guarded = guarded.replace(pattern, "[具体时间已省略]");
for (const pattern of guaranteeConclusionPatterns) {
guarded = guarded.replace(pattern, (sentence) => {
const prefix = /^[。!?.!?\n]/.exec(sentence)?.[0] ?? "";
return prefix + "[保证性结论已省略]";
});
}
return guarded;
}
+6
View File
@@ -30,6 +30,7 @@ const workflowConsumerContextSchema = z.object({
available_layers: z.array(z.string()),
missing_route_layers: z.array(z.string()),
hard_blockers: z.array(z.string()),
technique_truth: z.record(z.unknown()).optional(),
answer_policy: z.object({
can_answer_direction: z.boolean(),
can_answer_precise_timing: z.boolean(),
@@ -85,6 +86,8 @@ export function consultationWorkflowReceipt(data: JsonRecord) {
status: consumerContext.core_status,
preciseTiming: consumerContext.answer_policy.can_answer_precise_timing ? "allowed" : "blocked",
missingLayers: consumerContext.missing_route_layers.join(",") || "none",
techniqueTruth: String(record(consumerContext.technique_truth).status || "unknown"),
evidenceStatus: record(consumerContext.commercial_evidence_status),
};
}
@@ -110,6 +113,8 @@ export function toAgentConsultationContext(data: JsonRecord) {
available_layers: consumerContext.available_layers,
missing_route_layers: consumerContext.missing_route_layers,
hard_blockers: consumerContext.hard_blockers,
technique_truth: consumerContext.technique_truth,
commercial_evidence_status: consumerContext.commercial_evidence_status,
answer_policy: consumerContext.answer_policy,
user_facing_limitation: consumerContext.user_facing_limitation,
},
@@ -180,6 +185,7 @@ When reference_transparency is present:
- Only say the chart calculation failed when hard_blockers is non-empty.
- Never claim D2, D11, D9, D10, A10, UL, or Narayana Dasha is missing when it appears in available_layers, chart, or local_layers.
- Treat evidence_contract.answer_policy as a hard output contract. When can_answer_precise_timing is false, provide only direction or structure and do not state a month, date, or guaranteed timing outcome.
- Treat answer_policy.deterministic_claims_forbidden_for as a hard prohibition. Do not use a restricted technique to make a deterministic conclusion. reference_only, partial, blocked, research_only_blocked, and partial_registry_only are commercial claim boundaries, not validated capabilities.
- Treat rectification.boundary=not_auto_rectified as final: a candidate time or score is not a verified birth time and must not be presented as one.
Usually answer in 2-5 short paragraphs. Ask one clarifying question only when the user's intent is genuinely unclear.
After every substantive answer, append exactly two hidden blocks in this order and nothing after the second block:
@@ -36,7 +36,7 @@ test("fake Agent and engine complete baseline, adaptive, low, and no-apply flow"
assert.equal(turn.snapshot.activeTime, null);
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
await assert.rejects(harness.candidateActions.confirm({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[8], expectedVersion: turn.turnVersion,
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[10], expectedVersion: turn.turnVersion,
resultId: turn.candidateResult?.resultId ?? "", time: "14:24",
}));
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
@@ -171,6 +171,7 @@ test("server renders every approved variant from its QuestionSpec domain and pre
relocation: /搬家|离乡|居住地/,
relationship: /关系进入|关系结束|关系.*转变/,
career: /工作|职业方向|身份变化/,
finance: /收入|资产|负债|资源渠道/,
health_pressure: /健康压力|生活压力/,
} as const;
for (const domain of evidenceDomains) {
@@ -5,6 +5,45 @@ import {
isGuidedBirthTimePreview,
} from "../src/lib/birth-time-guided-preview.ts";
/* Legacy pre-dynamic-choice contract assertions intentionally omitted. */
/*
const source = readFileSync(
new URL("../src/components/birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const candidateSource = readFileSync(
new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(
new URL("../src/app/page.tsx", import.meta.url),
"utf8",
);
const storeSource = readFileSync(
new URL("../src/lib/birth-time-journey-store.ts", import.meta.url),
"utf8",
);
const routeSource = readFileSync(
new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url),
"utf8",
);
const stylesSource = readFileSync(
new URL("../src/app/globals.css", import.meta.url),
"utf8",
);
test("rectification UI is driven only by the persisted guided action", () => {
assert.match(source, /journey\.nextAction/);
assert.doesNotMatch(source, /questions\.slice\(0, 3\)/);
assert.doesNotMatch(source, /nextRoundQuestions/);
});
test("does not present zero-event output as a completed rectification", () => {
assert.match(candidateSource, /eventCount === 0/);
assert.match(candidateSource, /尚未进入分钟计算/);
});
*/
test("development previews cover every guided state with legal persisted actions", () => {
const expectedActions = new Map([
["birth-time-rectification", "ask_dynamic_choice"],
@@ -9,7 +9,7 @@ test("runs the Jyotish workflow before streaming a commercial consultation", ()
assert.match(route, /runConsultationWorkflow/);
assert.match(route, /await runConsultationWorkflow\(toolInput\)/);
assert.match(route, /getJyotishAgent\(selectedModel, workflowContext\)\.stream/);
assert.ok(route.indexOf("await runConsultationWorkflow(toolInput)") < route.indexOf(".stream(["));
assert.ok(route.indexOf("await runConsultationWorkflow(toolInput)") < route.indexOf(".stream("));
});
test("grounds the answer in the server-computed workflow without a second tool run", () => {
@@ -26,3 +26,11 @@ test("validates and emits a non-sensitive workflow receipt", () => {
assert.match(route, /x-jyotish-workflow-route/);
assert.match(route, /x-jyotish-workflow-status/);
});
test("carries commercial technique truth into the model contract", () => {
assert.match(mastra, /technique_truth/);
assert.match(mastra, /deterministic_claims_forbidden_for/);
assert.match(mastra, /reference_only/);
assert.match(mastra, /Do not use a restricted technique/);
assert.match(route, /x-jyotish-technique-truth/);
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { guardPreciseTimingOutput } from "../src/lib/timing-output-guard.ts";
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
test("removes exact dates and months when precise timing is blocked", () => {
const guarded = guardPreciseTimingOutput(
"你会在2027年3月15日结婚,事业将在11月转折。",
);
assert.doesNotMatch(guarded, /2027年3月15日|11月/);
assert.match(guarded, /具体时间已省略/);
});
test("removes guarantee conclusions when the evidence contract is incomplete", () => {
const guarded = guardPreciseTimingOutput("我保证你一定会升职。");
assert.doesNotMatch(guarded, /保证你一定会升职/);
assert.match(guarded, /保证性结论已省略/);
assert.equal(
guardPreciseTimingOutput("You will definitely get promoted."),
"[保证性结论已省略].",
);
});
test("guards a date that crosses streamed chunks", async () => {
async function* reply() {
yield "应期是2027年";
yield "3月15日,但我保证你一定会升职。";
}
const response = streamTextResponse(reply(), {
transformText: guardPreciseTimingOutput,
});
const text = await response.text();
assert.doesNotMatch(text, /2027年3月15日|保证你一定会升职/);
assert.match(text, /保证性结论已省略/);
});
@@ -0,0 +1,47 @@
{
"schema_version": 1,
"scope": "commercial_claim_contract",
"source_policy": "Commercial status boundaries only. This artifact contains no research formulas, private source material, raw cases, or tuning data.",
"techniques": [
{
"technique_id": "kp_system",
"status": "reference_only",
"claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion."
},
{
"technique_id": "muhurta",
"status": "reference_only",
"claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion."
},
{
"technique_id": "gochara_event_timing",
"status": "reference_only",
"claim_boundary": "May be named as background context, never as a primary or deterministic commercial conclusion."
},
{
"technique_id": "sahams",
"status": "blocked",
"claim_boundary": "Do not present as an available commercial calculation or conclusion."
},
{
"technique_id": "sphuta_trisphuta_family",
"status": "blocked",
"claim_boundary": "Do not present as an available commercial calculation or conclusion."
},
{
"technique_id": "tajika_yogas",
"status": "partial",
"claim_boundary": "Do not support deterministic conclusions, exact timing, or guarantees."
},
{
"technique_id": "conception_chart",
"status": "research_only_blocked",
"claim_boundary": "Not available in the commercial product."
},
{
"technique_id": "relationship_combinations",
"status": "partial_registry_only",
"claim_boundary": "Registry reference only; do not present as a verified commercial calculation."
}
]
}
+156 -20
View File
@@ -32,16 +32,22 @@ if str(SCRIPTS) not in sys.path:
import dasha_analyzer # noqa: E402
import domain_calculation_service # noqa: E402
import ashtakavarga # noqa: E402
import divisional_charts_extended # noqa: E402
import functional_benefics # noqa: E402
import jaimini # noqa: E402
import shadbala # noqa: E402
import narayana_dasha # noqa: E402
import varga # noqa: E402
DomainConfig = tuple[str, tuple[int, ...]]
DomainConfig = tuple[tuple[str, ...], tuple[int, ...]]
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
"education": ("D24", (4, 5, 9)),
"relocation": ("D4", (4, 12)),
"relationship": ("D9", (7,)),
"career": ("D10", (10,)),
"health_pressure": ("D30", (6, 8, 12)),
"education": (("D24",), (4, 5, 9)),
"relocation": (("D4",), (4, 12)),
"relationship": (("D9",), (7,)),
"career": (("D10",), (10,)),
"finance": (("D2", "D11"), (2, 11)),
"health_pressure": (("D30",), (6, 8, 12)),
}
@@ -77,7 +83,7 @@ def _active_vimshottari(
birth_date: str,
moon_longitude: float,
event_at: datetime,
) -> tuple[str, str]:
) -> tuple[str, str, str]:
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
birth_date,
@@ -89,7 +95,11 @@ def _active_vimshottari(
dasha_analyzer.build_antardasha(major),
event_at,
)
return str(major["lord"]), str(minor["lord"])
pratyantar = dasha_analyzer.find_current_sub(
dasha_analyzer.build_antardasha(minor),
event_at,
)
return str(major["lord"]), str(minor["lord"]), str(pratyantar["lord"])
def _active_narayana(
@@ -116,6 +126,20 @@ def _varga_chart(charts: dict, prefix: str) -> dict | None:
)
def _d11_chart(planet_longitudes: dict[str, float], ascendant_longitude: float) -> dict:
"""Adapt the repository's Rudramsa implementation to the event-score shape."""
raw = divisional_charts_extended.DivisionalChartsCalculator().calculate_all_vargas(
planet_longitudes, ascendant_longitude,
)["Rudramsa"]
return {
"Ascendant": {"sign_idx": raw["ascendant"]["sign_index"]},
**{
planet: {"sign_idx": value["sign_index"]}
for planet, value in raw["planets"].items()
},
}
def _relative_house(sign_index: int, ascendant_index: int) -> int:
return (sign_index - ascendant_index) % 12 + 1
@@ -149,20 +173,27 @@ def _score_event(
candidate_time: str,
event: LifeEvent,
natal_chart: dict,
varga_chart: dict,
vimshottari: tuple[str, str],
varga_charts: list[dict],
vimshottari: tuple[str, str, str],
narayana: tuple[int | None, int | None],
arudha_padas: dict,
) -> CandidateEvidence:
_, target_houses = DOMAIN_CONFIG[event["domain"]]
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
target_lords = _house_lords(ascendant_index, target_houses)
major_lord, minor_lord = vimshottari
functional = functional_benefics.derive_functional_benefic_malefic(
natal_chart["ascendant"].get("sign")
)
functional_benefics_set = set(functional.get("functional_benefics") or [])
functional_malefics_set = set(functional.get("functional_malefics") or [])
major_lord, minor_lord, pratyantar_lord = vimshottari
rules: list[str] = []
points = 0.0
for lord, weight, label in (
(major_lord, 2.0, "vim_md"),
(minor_lord, 1.5, "vim_ad"),
(pratyantar_lord, 0.75, "vim_pd"),
):
if _planet_house(natal_chart, lord) in target_houses:
rules.append(f"{label}_domain_house")
@@ -170,9 +201,16 @@ def _score_event(
if lord in target_lords:
rules.append(f"{label}_domain_lord")
points += weight
if _varga_house(varga_chart, lord) in target_houses:
rules.append(f"{label}_domain_varga")
points += weight / 2
for varga_chart in varga_charts:
if _varga_house(varga_chart, lord) in target_houses:
rules.append(f"{label}_domain_varga")
points += weight / (2 * len(varga_charts))
if lord in functional_benefics_set:
rules.append(f"{label}_functional_benefic_auxiliary")
points += 0.2
elif lord in functional_malefics_set:
rules.append(f"{label}_functional_malefic_auxiliary")
points -= 0.1
for sign_index, weight, label in (
(narayana[0], 2.0, "narayana_md"),
@@ -181,6 +219,17 @@ def _score_event(
if sign_index is not None and _relative_house(sign_index, ascendant_index) in target_houses:
rules.append(f"{label}_domain_house")
points += weight
arudha_keys = ("A7", "UL") if event["domain"] == "relationship" else ("A10",) if event["domain"] == "career" else ()
arudha_signs = {
value.get("sign_idx") for key in arudha_keys
if isinstance((value := arudha_padas.get(key)), dict) and isinstance(value.get("sign_idx"), int)
}
if arudha_signs:
for lord, label in ((major_lord, "vim_md"), (minor_lord, "vim_ad"), (pratyantar_lord, "vim_pd")):
planet = natal_chart.get("planets", {}).get(lord) or {}
if isinstance(planet.get("lon"), (int, float)) and int(planet["lon"] // 30) in arudha_signs:
rules.append(f"{label}_arudha_auxiliary")
points += 0.35
weighted_points = round(points * precision_weight(event["precision"]), 4)
return {
@@ -192,6 +241,76 @@ def _score_event(
}
def _controlled_transit_rules(
request: RectificationEventRequest,
event: LifeEvent,
natal_ascendant_index: int,
target_houses: tuple[int, ...],
) -> list[str]:
"""Use only Jupiter/Saturn and only day/month dated events as a weak check."""
if event["precision"] == "year":
return []
event_at = _event_datetime(event)
transit_chart = domain_calculation_service.compute_chart({
"year": event_at.year, "month": event_at.month, "day": event_at.day,
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
"tz": request["tz"], "ayanamsa": "lahiri", "node_mode": "true",
})
rules: list[str] = []
for planet in ("Jupiter", "Saturn"):
item = transit_chart.get("planets", {}).get(planet) or {}
if isinstance(item.get("lon"), (int, float)) and _relative_house(int(item["lon"] // 30), natal_ascendant_index) in target_houses:
rules.append(f"controlled_transit_{planet.lower()}_domain_house")
return rules
def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_houses: tuple[int, ...]) -> tuple[list[str], float]:
"""Return a bounded SAV consistency adjustment, never a standalone trigger."""
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"):
return [], 0.0
house_scores = result.get("house_scores_full") or {}
values = [house_scores.get(f"house_{house}", {}).get("sav_score") for house in target_houses]
numeric = [float(value) for value in values if isinstance(value, (int, float))]
if not numeric:
return [], 0.0
average = sum(numeric) / len(numeric)
if average >= 32:
return ["ashtakavarga_target_house_support_auxiliary"], 0.2
if average <= 24:
return ["ashtakavarga_target_house_pressure_auxiliary"], -0.1
return [], 0.0
def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float, dasha_lords: tuple[str, str, str]) -> tuple[list[str], float]:
"""Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched."""
planets = natal_chart.get("planets", {})
sun = planets.get("Sun") or {}
moon = planets.get("Moon") or {}
if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)):
return [], 0.0
result = shadbala.calc_shadbala(
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
float(sun["lon"]), float(moon["lon"]),
)
values = {
planet: float((row.get("sthana_bala") or {}).get("total", 0)) + float(row.get("drik_bala", 0)) + float(row.get("naisargika_bala", 0))
for planet, row in (result.get("planets") or {}).items()
}
if not values:
return [], 0.0
baseline = sum(values.values()) / len(values)
active = [values[lord] for lord in dasha_lords if lord in values]
if not active:
return [], 0.0
average = sum(active) / len(active)
if average > baseline:
return ["shadbala_sthana_drik_naisargika_support_auxiliary"], 0.1
if average < baseline:
return ["shadbala_sthana_drik_naisargika_pressure_auxiliary"], -0.05
return [], 0.0
def _candidate_row(
request: RectificationEventRequest,
candidate_at: datetime,
@@ -215,21 +334,23 @@ def _candidate_row(
}
ascendant_longitude = float(chart["ascendant"]["lon"])
ascendant_index = int(ascendant_longitude // 30)
arudha_padas = (jaimini.calc_arudha_padas(ascendant_index, planet_longitudes).get("padas") or {})
charts = varga.calc_all_vargas(
planet_longitudes,
ascendant_longitude,
divisions=[4, 9, 10, 24, 30],
divisions=[2, 4, 9, 10, 24, 30],
)
d11_chart = _d11_chart(planet_longitudes, ascendant_longitude)
moon_longitude = planet_longitudes["Moon"]
evidence: list[CandidateEvidence] = []
missing_layers: list[str] = []
for event in request["events"]:
event_at = _event_datetime(event)
prefix, _ = DOMAIN_CONFIG[event["domain"]]
domain_varga = _varga_chart(charts, prefix)
if domain_varga is None:
missing_layers.append(prefix)
prefixes, _ = DOMAIN_CONFIG[event["domain"]]
domain_vargas = [d11_chart if prefix == "D11" else _varga_chart(charts, prefix) for prefix in prefixes]
if any(chart is None for chart in domain_vargas):
missing_layers.extend(prefixes)
continue
vimshottari = _active_vimshottari(request["birth_date"], moon_longitude, event_at)
narayana = _active_narayana(
@@ -242,10 +363,25 @@ def _candidate_row(
candidate_time=candidate_at.strftime("%H:%M"),
event=event,
natal_chart=chart,
varga_chart=domain_varga,
varga_charts=[chart for chart in domain_vargas if chart is not None],
vimshottari=vimshottari,
narayana=narayana,
arudha_padas=arudha_padas,
))
transit_rules = _controlled_transit_rules(request, event, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
if transit_rules:
evidence[-1]["rule_ids"].extend(transit_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + 0.25 * len(transit_rules) * precision_weight(event["precision"]), 4)
av_rules, av_points = _ashtakavarga_auxiliary(chart, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
if av_rules:
evidence[-1]["rule_ids"].extend(av_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4)
shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary(
chart, candidate_at.hour + candidate_at.minute / 60, vimshottari,
)
if shadbala_rules:
evidence[-1]["rule_ids"].extend(shadbala_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + shadbala_points * precision_weight(event["precision"]), 4)
return {
"time": candidate_at.strftime("%H:%M"),
+1
View File
@@ -18,6 +18,7 @@ EventDomain = Literal[
"relocation",
"relationship",
"career",
"finance",
"health_pressure",
]
Confidence = Literal["low", "medium", "high"]
+27 -10
View File
@@ -179,6 +179,25 @@ def _kp_cusp_snapshot(chart: dict[str, Any]) -> dict[str, Any]:
return snapshot
def _prioritize_questions(questions: list[dict[str, Any]], scan: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
"""Prefer questions whose declared layers actually differ in sampled candidates."""
samples = scan.get("samples") or []
if len(samples) < 2 or not all(isinstance(sample.get("varga_lagna"), dict) for sample in samples):
return questions, "generic_fallback_missing_candidate_recast"
changed: set[str] = set()
for layer in ("D4", "D9", "D10", "D24", "D30"):
values = {str((sample["varga_lagna"].get(layer) or {}).get("sign_idx")) for sample in samples}
if len(values) > 1:
changed.add(layer)
for layer in ("A7", "A10", "UL"):
values = {str(((sample.get("arudha") or {}).get(layer) or {}).get("sign_idx")) for sample in samples}
if len(values) > 1:
changed.add(layer)
if not changed:
return questions, "generic_fallback_no_sampled_difference"
return sorted(questions, key=lambda question: (not bool(changed.intersection(question.get("sensitivity") or [])), question.get("round", 99))), "candidate_difference_ranked"
def build_questionnaire(
birth_time: str,
uncertainty_minutes: int = 30,
@@ -206,18 +225,16 @@ def build_questionnaire(
"D": {"effect": "neutral", "cluster": "neutral", "points": 0},
},
})
scan = _candidate_scan(
_parse_time(birth_time), uncertainty_minutes, step_minutes,
lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa,
)
questions, question_selection = _prioritize_questions(questions, scan)
return {
"scope": "active_birth_time_rectification_questionnaire",
"schema_version": 1,
"candidate_scan": _candidate_scan(
_parse_time(birth_time),
uncertainty_minutes,
step_minutes,
lat=lat,
lon=lon,
tz=tz,
ayanamsa=ayanamsa,
),
"candidate_scan": scan,
"question_selection": question_selection,
"workflow": [
"candidate_time_scan",
"varga_arudha_kp_sensitivity_diff",
@@ -231,7 +248,7 @@ def build_questionnaire(
"2": "domain follow-up",
"3": "fine confirmation",
},
"sensitivity_layers": ["D9", "D10", "D24", "D30", "D60", "D4", "UL", "A7", "A10", "KP_cusp", "Vimshottari", "Narayana", "Chara"],
"sensitivity_layers": ["D9", "D10", "D24", "D30", "D60", "D4", "UL", "A7", "A10", "Vimshottari", "Narayana", "Chara"],
"questions": questions,
"boundary": "Question generation only; final rectification requires scoring answers against actual candidate chart differences.",
}
+1
View File
@@ -88,6 +88,7 @@ QUESTION_TEMPLATES: Final[tuple[QuestionTemplate, ...]] = (
QuestionTemplate("residence_relocation_shift", 1, "residence", ("D4", "12H", "Rahu/Ketu", "Transit"), "age_20_to_24", "20-24岁附近,是否有搬家、离乡、长期异地、住宿或居住结构变化?", "D4_relocation_cluster", "against_D4_relocation_cluster"),
QuestionTemplate("relationship_or_partner_entry", 1, "relationship", ("D9", "UL", "A7", "7H"), "age_21_to_26", "21-26岁附近,是否有关系对象进入、关系断裂、暧昧升级或关系观明显转变?", "D9_UL_A7_cluster", "against_relationship_cluster"),
QuestionTemplate("career_responsibility_pressure", 1, "career", ("D10", "A10", "Saturn", "10H"), "age_26_to_30", "26-30岁附近,是否有责任增加、合作压力、工作结构变化或长期压力阶段?", "D10_A10_saturn_cluster", "against_career_pressure_cluster"),
QuestionTemplate("finance_resource_shift", 1, "finance", ("D2", "2H", "11H"), "resource_change_window", "是否有收入结构、重要资产、资助、负债或资源渠道发生明显变化的阶段?", "D2_resource_cluster", "against_D2_resource_cluster"),
QuestionTemplate("research_tool_expression_shift", 1, "career_learning", ("D10", "D24", "Mercury", "A10"), "recent_three_years", "近三年是否明显进入写作、技术、系统化学习、工具搭建、内容表达、AI/研究类方向?", "Mercury_D24_A10_cluster", "against_learning_expression_cluster"),
QuestionTemplate("health_crisis_or_low_period", 2, "health_pressure", ("D30", "6H", "8H", "Saturn/Mars"), "largest_pressure_window", "某个压力窗口附近,是否有健康、事故、低谷、睡眠/精神压力或身体负担明显阶段?", "D30_crisis_cluster", "against_D30_crisis_cluster"),
QuestionTemplate("public_role_or_project_visibility", 2, "public_work", ("A10", "D10", "AmK", "Karakamsha"), "career_visibility_window", "某个事业窗口附近,是否有项目公开、作品产出、职位/身份变化或被他人看见的机会?", "A10_public_visibility_cluster", "against_A10_cluster"),
+28 -1
View File
@@ -234,13 +234,39 @@ def source_referenced_scripts(*texts: str) -> set[str]:
refs: set[str] = set()
for path in SCRIPTS_DIR.glob("*.py"):
stem = path.stem
if re.search(rf"\b(import|from)\s+{re.escape(stem)}\b", combined):
if re.search(rf"\b(import|from)\s+(?:scripts\.)?{re.escape(stem)}\b", combined):
refs.add(path.name)
if path.name in combined or stem in combined:
refs.add(path.name)
return refs
def transitive_source_referenced_scripts(*texts: str) -> set[str]:
"""Follow local script imports so indirect runtime modules are not fragments."""
combined = "\n".join(texts)
names = set(re.findall(r"(?:from|import)\s+(?:scripts\.)?([A-Za-z_][A-Za-z0-9_]*)", combined))
names |= set(re.findall(r"_load_local_module\(['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\)", combined))
referenced = {f"{name}.py" for name in names if (SCRIPTS_DIR / f"{name}.py").exists()}
pending = list(referenced)
visited: set[str] = set()
while pending:
filename = pending.pop()
if filename in visited:
continue
visited.add(filename)
path = SCRIPTS_DIR / filename
if not path.exists():
continue
text = read_text(path)
child_names = set(re.findall(r"(?:from|import)\s+(?:scripts\.)?([A-Za-z_][A-Za-z0-9_]*)", text))
child_names |= set(re.findall(r"_load_local_module\(['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\)", text))
for child in {f"{name}.py" for name in child_names if (SCRIPTS_DIR / f"{name}.py").exists()}:
if child not in referenced:
referenced.add(child)
pending.append(child)
return referenced
def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], test_text: str) -> dict[str, Any]:
api_text = read_text(SCRIPTS_DIR / "jyotish_api_server.py")
engine_text = read_text(SCRIPTS_DIR / "jyotish_engine.py")
@@ -248,6 +274,7 @@ def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], te
referenced = set(SCRIPT_IGNORE)
referenced |= registry_script_refs(registry)
referenced |= source_referenced_scripts(api_text, engine_text, app_text, test_text)
referenced |= transitive_source_referenced_scripts(api_text, engine_text, app_text, test_text)
candidates = []
for path in sorted(SCRIPTS_DIR.glob("*.py")):
if path.name in referenced:
+108
View File
@@ -0,0 +1,108 @@
"""Load and apply commercial claim boundaries for restricted techniques.
This is deliberately a product-owned status contract. It never imports a research
workspace or reproduces research calculations.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any
OVERLAY_PATH = (
Path(__file__).resolve().parents[1]
/ "references"
/ "oracle"
/ "commercial_skill_truth_overlay.v1.json"
)
ORACLE_DIR = OVERLAY_PATH.parent
TECHNIQUE_TRUTH_IDS = (
"kp_system",
"muhurta",
"gochara_event_timing",
"sahams",
"sphuta_trisphuta_family",
"tajika_yogas",
"conception_chart",
"relationship_combinations",
)
_BLOCKED_STATUSES = {"blocked", "research_only_blocked"}
def load_commercial_skill_truth() -> dict[str, Any]:
"""Return the local, public-safe commercial status contract."""
with OVERLAY_PATH.open(encoding="utf-8") as handle:
payload = json.load(handle)
techniques = payload.get("techniques")
if not isinstance(techniques, list):
raise ValueError("commercial technique truth overlay must contain techniques")
by_id = {item.get("technique_id"): item for item in techniques if isinstance(item, dict)}
if set(by_id) != set(TECHNIQUE_TRUTH_IDS):
raise ValueError("commercial technique truth overlay has an unexpected technique set")
return payload
def apply_commercial_skill_truth(result: dict[str, Any]) -> dict[str, Any]:
"""Attach immutable claim limits to a server workflow receipt."""
enriched = copy.deepcopy(result)
techniques = load_commercial_skill_truth()["techniques"]
blocked = [item["technique_id"] for item in techniques if item["status"] in _BLOCKED_STATUSES]
restricted = [item["technique_id"] for item in techniques]
enriched["technique_truth"] = {
"status": "restricted",
"techniques": techniques,
"blocked_techniques": blocked,
"reference_only_techniques": [
item["technique_id"] for item in techniques if item["status"] == "reference_only"
],
"partial_techniques": [
item["technique_id"]
for item in techniques
if item["status"] in {"partial", "partial_registry_only"}
],
}
answer_policy = enriched.setdefault("answer_policy", {})
answer_policy["deterministic_claims_forbidden_for"] = restricted
answer_policy["blocked_techniques"] = blocked
answer_policy["technique_truth_status"] = "restricted"
evidence_status = _commercial_evidence_status()
enriched["commercial_evidence_status"] = evidence_status
consumer_context = enriched.get("consumer_context")
if isinstance(consumer_context, dict):
consumer_context["technique_truth"] = enriched["technique_truth"]
consumer_context["commercial_evidence_status"] = evidence_status
return enriched
def _read_local_object(filename: str) -> dict[str, Any]:
try:
with (ORACLE_DIR / filename).open(encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _commercial_evidence_status() -> dict[str, Any]:
"""Summarize local evidence state without returning raw external responses."""
vedastro = _read_local_object("vedastro_identity_archive_2026_07_19.json")
mismatch = _read_local_object("three_engine_mismatch_arbitration_2026_07_19.json")
return {
"claim_audit": {
"status": "contract_enforced",
"scope": "commercial_claim_boundaries",
},
"vedastro_identity": {
"status": vedastro.get("self_host_candidate_status") or "not_archived",
"hosted_identity": "runtime_evidence_required",
},
"three_engine_mismatch": {
"status": mismatch.get("status") or "not_assessed",
"truth_policy": mismatch.get("truth_policy") or "no_majority_vote",
"mismatch_count": mismatch.get("mismatch_count"),
"category_counts": mismatch.get("category_counts") or {},
},
}
+46 -6
View File
@@ -40,8 +40,10 @@ if SCRIPTS_DIR not in sys.path:
try:
from scripts.local_env import load_local_env
from scripts.vedastro_runtime_context import temporary_timeout_seconds
except ModuleNotFoundError: # pragma: no cover - script execution path
from local_env import load_local_env
from vedastro_runtime_context import temporary_timeout_seconds
try:
from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator
except ModuleNotFoundError: # pragma: no cover - script execution path
@@ -572,7 +574,8 @@ def execute_consultation_workflow(
result['success'] = False
result['blocked_reason'] = 'external_parity_not_passed'
result['timing_precision_contract'] = build_timing_precision_contract(body.get('timing'))
return result
from scripts.commercial_skill_truth import apply_commercial_skill_truth
return apply_commercial_skill_truth(result)
chart = dict(chart_override) if isinstance(chart_override, dict) else {}
prashna = {}
@@ -779,7 +782,8 @@ def execute_consultation_workflow(
timing=body.get('timing'),
reference_date=_consultation_reference_date(body).date().isoformat(),
)
return result
from scripts.commercial_skill_truth import apply_commercial_skill_truth
return apply_commercial_skill_truth(result)
def _load_local_module(module_name):
@@ -841,12 +845,16 @@ def _vedastro_runtime_fingerprint() -> dict:
'endpoint_host': (urlparse(endpoint).netloc or '').lower(),
'network_enabled': str(os.environ.get('VEDASTRO_ENABLE_NETWORK', '')).strip().lower() in {'1', 'true', 'yes'},
'has_api_key': bool(os.environ.get('VEDASTRO_API_KEY', '').strip()),
'timeout_seconds': str(os.environ.get('VEDASTRO_TIMEOUT_SECONDS', '')).strip(),
'full_snapshot_fanout_enabled': str(
os.environ.get('VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED', '1')
).strip().lower() in {'1', 'true', 'yes', 'on'},
}
def _build_api_chart_cache_payload(body: dict) -> dict:
return {
'cache_schema_version': 3,
'cache_schema_version': 4,
'birth': {
'year': body.get('year'),
'month': body.get('month'),
@@ -960,6 +968,14 @@ def _async_job_ttl_seconds() -> float:
return 3600.0
def _async_high_rigor_vedastro_timeout_seconds() -> float:
raw = str(os.environ.get('JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS', '90')).strip()
try:
return min(max(float(raw), 30.0), 180.0)
except ValueError:
return 90.0
def _async_job_backend() -> str:
return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file"
@@ -2759,7 +2775,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
_write_high_rigor_job_record(job_id, running)
try:
result = self._compute_high_rigor_workflow_sync(body_copy)
with temporary_timeout_seconds(_async_high_rigor_vedastro_timeout_seconds()):
result = self._compute_high_rigor_workflow_sync(body_copy)
completed = dict(running)
completed['status'] = 'completed'
completed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
@@ -6884,13 +6901,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
def _compute_active_rectification_events(self, body):
allowed_fields = {
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events',
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events', 'high_rigor',
}
unsupported_fields = sorted(set(body) - allowed_fields)
if unsupported_fields:
raise BadRequest(
f'unsupported active rectification event field: {unsupported_fields[0]}'
)
high_rigor = body.get('high_rigor', False)
if not isinstance(high_rigor, bool):
raise BadRequest('high_rigor must be a boolean')
birth_date = body.get('birth_date')
start_time = body.get('start_time')
end_time = body.get('end_time')
@@ -6911,7 +6931,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if not isinstance(events, list) or not 3 <= len(events) <= 6:
raise BadRequest('events must contain between 3 and 6 items')
normalized_events = []
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'health_pressure'}
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'}
formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
for raw_event in events:
if not isinstance(raw_event, dict) or set(raw_event) != {'id', 'domain', 'date', 'precision'}:
@@ -6949,6 +6969,26 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'tz': tz,
'events': normalized_events,
})
from scripts.rectification_technique_contract import build_rectification_technique_contract
result['technique_contract'] = build_rectification_technique_contract(
event_count=result.get('event_count', 0),
domain_count=result.get('domain_count', 0),
high_rigor=high_rigor,
)
if high_rigor:
from scripts.rectification_three_engine_packet import build_packet
result['three_engine_packet'] = build_packet({
'year': parsed_birth_date.year,
'month': parsed_birth_date.month,
'day': parsed_birth_date.day,
'hour': int(start_time.split(':', 1)[0]),
'minute': int(start_time.split(':', 1)[1]),
'lat': lat,
'lon': lon,
'tz': tz,
})
result['can_apply'] = False
result.setdefault('reasons', []).append('three_engine_parity_not_passed')
return {
'success': True,
'endpoint': 'active_rectification_events',
@@ -0,0 +1,28 @@
"""Commercial claim contract for birth-time rectification receipts."""
from __future__ import annotations
from typing import Any
def build_rectification_technique_contract(*, event_count: int, domain_count: int, high_rigor: bool = False) -> dict[str, Any]:
blockers: list[str] = []
if event_count < 3:
blockers.append("insufficient_events")
if domain_count < 2:
blockers.append("insufficient_domains")
if high_rigor:
blockers.append("three_engine_parity_not_passed")
return {
"schema_version": 1,
"calculation_status": "not_started" if event_count == 0 else "evaluated",
"used_divisional_charts": ["D4", "D9", "D10", "D24", "D30"],
"used_arudha": ["A7", "UL", "A10"],
"dasha_tracks": ["vimshottari_md_ad_pd", "narayana_md_ad"],
"missing_layers": ["shadbala_kala_dig_chesta_total"],
"partial_layers": ["D2", "D11", "shadbala_sthana_drik_naisargika"],
"auxiliary_layers": ["functional_benefic_malefic", "controlled_transit", "ashtakavarga", "shadbala_verified_components"],
"external_engines": {"status": "required_not_run" if high_rigor else "not_run", "providers": ["pyjhora", "jyotishganit", "vedastro"]},
"hard_blockers": blockers,
"can_narrow_to_minute": False,
"boundary": "A candidate range is not a confirmed birth minute.",
}
@@ -0,0 +1,79 @@
"""Build a privacy-safe, request-level three-engine rectification parity packet."""
from __future__ import annotations
import hashlib
import importlib
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from domain_calculation_service import compute_chart
ROOT = Path(__file__).resolve().parents[1]
JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit"
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces")
def case_hash(case: dict[str, Any]) -> str:
"""Stable identity for evidence correlation; never exposes birth data."""
payload = json.dumps(case, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()
def _local_d1(case: dict[str, Any]) -> dict[str, str]:
chart = compute_chart({**case, "ayanamsa": case.get("ayanamsa", "lahiri"), "node_mode": case.get("node_mode", "true")})
return {planet: str(chart["planets"][planet]["sign"]) for planet in PLANETS}
def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]:
utils = importlib.import_module("jhora.utils")
charts = importlib.import_module("jhora.horoscope.chart.charts")
drik = importlib.import_module("jhora.panchanga.drik")
jd = utils.julian_day_number((case["year"], case["month"], case["day"]), (case["hour"], case["minute"], case.get("second", 0)))
drik.set_ayanamsa_mode("LAHIRI", jd=jd)
place = drik.Place("request-level", case["lat"], case["lon"], case["tz"])
index_to_planet = {0: "Sun", 1: "Moon", 2: "Mars", 3: "Mercury", 4: "Jupiter", 5: "Venus", 6: "Saturn"}
return {index_to_planet[body]: SIGNS[int(position[0])] for body, position in charts.rasi_chart(jd, place) if body in index_to_planet}
def _jyotishganit_d1(case: dict[str, Any]) -> dict[str, str]:
sys.path.insert(0, str(JYOTISHGANIT_ROOT))
try:
from jyotishganit import calculate_birth_chart, get_birth_chart_json
chart = calculate_birth_chart(datetime(case["year"], case["month"], case["day"], case["hour"], case["minute"], case.get("second", 0)), case["lat"], case["lon"], case["tz"], location_name="request-level", name="request-level")
raw = get_birth_chart_json(chart)
return {str(item["celestialBody"]): str(item["sign"]) for house in raw["d1Chart"]["houses"] for item in house.get("occupants", []) if item.get("celestialBody") in PLANETS}
finally:
if str(JYOTISHGANIT_ROOT) in sys.path:
sys.path.remove(str(JYOTISHGANIT_ROOT))
def build_packet(case: dict[str, Any]) -> dict[str, Any]:
"""Compare local/PyJHora/jyotishganit D1 without persisting private input."""
required = {"year", "month", "day", "hour", "minute", "lat", "lon", "tz"}
if not required <= set(case):
raise ValueError("case is missing required birth fields")
outputs: dict[str, dict[str, str]] = {"local": _local_d1(case)}
engine_status: dict[str, str] = {"local": "ok"}
for name, runner in (("pyjhora", _pyjhora_d1), ("jyotishganit", _jyotishganit_d1)):
try:
outputs[name] = runner(case)
engine_status[name] = "ok"
except Exception as exc:
outputs[name] = {}
engine_status[name] = f"blocked:{exc.__class__.__name__}"
rows = [{"planet": planet, "values": {name: data.get(planet) for name, data in outputs.items()}, "status": "match" if len({data.get(planet) for data in outputs.values()}) == 1 else "mismatch"} for planet in PLANETS]
return {
"scope": "request_level_three_engine_d1_parity",
"case_hash": case_hash(case),
"engine_status": engine_status,
"match_count": sum(row["status"] == "match" for row in rows),
"mismatch_count": sum(row["status"] == "mismatch" for row in rows),
"rows": rows,
"vedastro": {"status": "requires_gateway_raw_archive"},
"can_confirm": False,
"boundary": "D1 parity alone never confirms a rectified minute; VedAstro raw and domain-level parity remain required.",
}
+1
View File
@@ -155,6 +155,7 @@ def _raw_response_archive(job_id: str, result: dict[str, Any]) -> dict[str, Any]
archive_path = _queue_dir() / archive_rel
archive_path.parent.mkdir(parents=True, exist_ok=True)
archive_path.write_text(json.dumps(raw, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
os.chmod(archive_path, 0o600)
return {
"status": "official_raw_response_archived",
"official_raw_response_available": True,
+24
View File
@@ -0,0 +1,24 @@
"""Thread-local execution controls shared by every VedAstro import path."""
from __future__ import annotations
from contextlib import contextmanager
import contextvars
_TIMEOUT_OVERRIDE_SECONDS: contextvars.ContextVar[float | None] = contextvars.ContextVar(
"vedastro_timeout_override_seconds",
default=None,
)
def timeout_override_seconds() -> float | None:
return _TIMEOUT_OVERRIDE_SECONDS.get()
@contextmanager
def temporary_timeout_seconds(seconds: float):
token = _TIMEOUT_OVERRIDE_SECONDS.set(max(1.0, float(seconds)))
try:
yield
finally:
_TIMEOUT_OVERRIDE_SECONDS.reset(token)
+6
View File
@@ -26,8 +26,10 @@ from urllib.parse import urlparse
try:
from scripts.local_env import load_local_env
from scripts.vedastro_runtime_context import timeout_override_seconds
except ModuleNotFoundError: # pragma: no cover - script execution path
from local_env import load_local_env
from vedastro_runtime_context import timeout_override_seconds
ROOT = Path(__file__).resolve().parents[1]
@@ -357,6 +359,9 @@ _FREE_TIER_REQUEST_LOCK = threading.Lock()
def _timeout_seconds() -> float:
override = timeout_override_seconds()
if override is not None:
return override
raw = os.environ.get(TIMEOUT_ENV, "").strip()
if not raw:
return DEFAULT_TIMEOUT_SECONDS
@@ -366,6 +371,7 @@ def _timeout_seconds() -> float:
return DEFAULT_TIMEOUT_SECONDS
def _backoff_seconds() -> float:
raw = os.environ.get(BACKOFF_ENV, "").strip()
if not raw:
+9
View File
@@ -103,3 +103,12 @@ def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path)
assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite"
with pytest.raises(api.JobAccessDenied):
api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong")
def test_async_high_rigor_vedastro_timeout_is_bounded(monkeypatch):
monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "10")
assert api._async_high_rigor_vedastro_timeout_seconds() == 30.0
monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "999")
assert api._async_high_rigor_vedastro_timeout_seconds() == 180.0
monkeypatch.setenv("JYOTISH_ASYNC_HIGH_RIGOR_VEDASTRO_TIMEOUT_SECONDS", "invalid")
assert api._async_high_rigor_vedastro_timeout_seconds() == 90.0
+9
View File
@@ -3317,6 +3317,15 @@ def test_api_chart_cache_key_tracks_vedastro_runtime_state(monkeypatch: pytest.M
)
assert key_disabled != key_enabled
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "20")
key_timeout_20 = jyotish_api_server._api_chart_cache_key(
jyotish_api_server._build_api_chart_cache_payload(payload)
)
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "90")
key_timeout_90 = jyotish_api_server._api_chart_cache_key(
jyotish_api_server._build_api_chart_cache_payload(payload)
)
assert key_timeout_20 != key_timeout_90
def test_high_rigor_plan_only_surfaces_chart_cache_and_queue_strategy() -> None:
@@ -0,0 +1,51 @@
"""Commercial-only claim boundaries for techniques not cleared for deterministic use."""
from __future__ import annotations
from scripts.commercial_skill_truth import (
TECHNIQUE_TRUTH_IDS,
apply_commercial_skill_truth,
load_commercial_skill_truth,
)
def test_truth_overlay_has_all_restricted_techniques_and_no_research_paths() -> None:
overlay = load_commercial_skill_truth()
techniques = {item["technique_id"]: item for item in overlay["techniques"]}
assert set(TECHNIQUE_TRUTH_IDS) == set(techniques)
assert {item["status"] for item in techniques.values()} == {
"reference_only",
"partial",
"blocked",
"research_only_blocked",
"partial_registry_only",
}
assert "/Users/" not in str(overlay)
assert "research truth overlay" not in str(overlay).lower()
def test_truth_overlay_cannot_bypass_server_answer_contract() -> None:
result = apply_commercial_skill_truth(
{
"route": "career",
"answer_policy": {
"can_answer_direction": True,
"can_answer_precise_timing": True,
},
}
)
truth = result["technique_truth"]
policy = result["answer_policy"]
assert truth["status"] == "restricted"
assert set(truth["blocked_techniques"]) == {
"sahams",
"sphuta_trisphuta_family",
"conception_chart",
}
assert set(policy["deterministic_claims_forbidden_for"]) == set(TECHNIQUE_TRUTH_IDS)
assert policy["can_answer_precise_timing"] is True
evidence = result["commercial_evidence_status"]
assert evidence["claim_audit"]["status"] == "contract_enforced"
assert evidence["three_engine_mismatch"]["truth_policy"] == "no_majority_vote"
@@ -0,0 +1,31 @@
from scripts.rectification_technique_contract import build_rectification_technique_contract
def test_zero_events_are_not_a_completed_rectification() -> None:
contract = build_rectification_technique_contract(event_count=0, domain_count=0)
assert contract["calculation_status"] == "not_started"
assert contract["can_narrow_to_minute"] is False
assert "insufficient_events" in contract["hard_blockers"]
def test_contract_discloses_used_and_missing_layers() -> None:
contract = build_rectification_technique_contract(event_count=4, domain_count=3)
assert {"D4", "D9", "D10", "D24", "D30"} <= set(contract["used_divisional_charts"])
assert contract["dasha_tracks"] == ["vimshottari_md_ad_pd", "narayana_md_ad"]
assert contract["used_arudha"] == ["A7", "UL", "A10"]
assert contract["missing_layers"] == ["shadbala_kala_dig_chesta_total"]
assert "D2" in contract["partial_layers"]
assert "D11" in contract["partial_layers"]
assert "functional_benefic_malefic" in contract["auxiliary_layers"]
assert "controlled_transit" in contract["auxiliary_layers"]
assert "ashtakavarga" in contract["auxiliary_layers"]
assert "shadbala_verified_components" in contract["auxiliary_layers"]
assert "shadbala_sthana_drik_naisargika" in contract["partial_layers"]
assert contract["external_engines"]["status"] == "not_run"
def test_high_rigor_requires_real_three_engine_evidence() -> None:
contract = build_rectification_technique_contract(event_count=4, domain_count=3, high_rigor=True)
assert contract["external_engines"]["status"] == "required_not_run"
assert "three_engine_parity_not_passed" in contract["hard_blockers"]
assert contract["can_narrow_to_minute"] is False
@@ -0,0 +1,15 @@
from scripts.rectification_three_engine_packet import build_packet, case_hash
CASE = {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 0.0, "lon": 0.0, "tz": 0.0}
def test_packet_is_private_and_never_confirms(monkeypatch) -> None:
monkeypatch.setattr("scripts.rectification_three_engine_packet._local_d1", lambda _: {"Sun": "Aries"})
monkeypatch.setattr("scripts.rectification_three_engine_packet._pyjhora_d1", lambda _: {"Sun": "Aries"})
monkeypatch.setattr("scripts.rectification_three_engine_packet._jyotishganit_d1", lambda _: {"Sun": "Aries"})
packet = build_packet(CASE)
assert packet["case_hash"] == case_hash(CASE)
assert "year" not in str(packet)
assert packet["can_confirm"] is False
assert packet["vedastro"]["status"] == "requires_gateway_raw_archive"
+1
View File
@@ -257,6 +257,7 @@ def test_gateway_completion_archives_official_raw_response(monkeypatch, tmp_path
assert archive["official_raw_response_available"] is True
path = tmp_path / archive["official_raw_response_path"]
assert path.exists()
assert path.stat().st_mode & 0o777 == 0o600
assert '"vedastro_official"' in path.read_text(encoding="utf-8")
@@ -87,6 +87,18 @@ def test_vedastro_official_subprocesses_use_adapter_timeout(monkeypatch) -> None
assert catalog["status"] == "official_full_capability_catalog_runtime_error"
def test_temporary_timeout_is_scoped_to_the_current_execution(monkeypatch) -> None:
from scripts import vedastro_service_adapter as adapter
from scripts.vedastro_runtime_context import temporary_timeout_seconds
monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "7")
assert adapter._timeout_seconds() == 7.0
with temporary_timeout_seconds(90):
assert adapter._timeout_seconds() == 90.0
assert adapter._timeout_seconds() == 7.0
def test_vedastro_official_subprocess_timeouts_are_controlled(monkeypatch) -> None:
from scripts import vedastro_service_adapter as adapter