From cf063834ca9f9f62dc8559913dddb5ccb8578999 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 18 Oct 2026 16:44:05 +0800 Subject: [PATCH] feat(synastry): store the upstream seven-item marriage match Move the bde5d615 engine and full-synastry skill in unchanged, save both profiles without billing or a page, and retire the old synastry module. Checked targeted pytest, tsc 0, lint 0 errors, service tests 6/6, and test:db 73 pass / 13 fail matching the Windows known list. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- CHANGELOG.md | 6 + SKILL.md | 4 +- docs/BUG_HISTORY.md | 80 ++ .../PROGRESS-marriage-match-p1-20261017.md | 48 + docs/tasks/README.md | 2 +- frontend/src/lib/marriage-match-service.ts | 241 ++++ ...010000_synastry_reports_marriage_match.sql | 67 + .../tests/database-marriage-match.test.ts | 80 ++ .../fixtures/marriage-match-snapshot.json | 562 ++++++++ .../synastry-full-dossier-truncated.json | 30 + frontend/tests/marriage-match-service.test.ts | 174 +++ references/quick-reference-guide.md | 2 +- references/technique-capability-matrix.md | 2 +- references/technique_registry.json | 10 +- scripts/api_heavy_compute_gate.py | 1 + scripts/api_scratch_retention.py | 1 + scripts/ashtakoot.py | 3 +- ...compatibility_skill_readiness_dashboard.py | 2 +- scripts/jyotish_api_server.py | 14 + scripts/jyotish_engine.py | 41 +- scripts/marriage_match.py | 467 +++++++ scripts/marriage_match_api.py | 106 ++ scripts/marriage_match_cli.py | 20 + scripts/synastry.py | 262 ---- scripts/synastry_full_dossier.py | 70 + scripts/technique_registry.py | 2 +- skills/jyotish-full-synastry-reading/SKILL.md | 284 ++++ .../agents/openai.yaml | 4 + .../chinese-synastry-writing-contract.md | 122 ++ .../references/customer-report-shape.md | 147 ++ .../references/customer-topic-priorities.md | 42 + .../english-synastry-writing-contract.md | 87 ++ .../full-technique-execution-matrix.md | 125 ++ .../interaction-progression-evidence-map.md | 70 + .../scripts/build_full_synastry_dossier.py | 1198 +++++++++++++++++ .../scripts/compose_full_synastry_delivery.py | 173 +++ .../scripts/prepare_full_synastry_packets.py | 188 +++ .../validate_bilingual_synastry_delivery.py | 288 ++++ .../scripts/validate_full_synastry_packets.py | 301 +++++ tests/run_all.py | 16 +- tests/test_marriage_match_api.py | 134 ++ tests/test_marriage_match_product.py | 149 ++ tests/test_open_source_integrations.py | 11 +- tests/test_synastry_full_dossier.py | 64 + 44 files changed, 5387 insertions(+), 313 deletions(-) create mode 100644 docs/tasks/PROGRESS-marriage-match-p1-20261017.md create mode 100644 frontend/src/lib/marriage-match-service.ts create mode 100644 frontend/supabase/migrations/20261018010000_synastry_reports_marriage_match.sql create mode 100644 frontend/tests/database-marriage-match.test.ts create mode 100644 frontend/tests/fixtures/marriage-match-snapshot.json create mode 100644 frontend/tests/fixtures/synastry-full-dossier-truncated.json create mode 100644 frontend/tests/marriage-match-service.test.ts create mode 100644 scripts/marriage_match.py create mode 100644 scripts/marriage_match_api.py create mode 100644 scripts/marriage_match_cli.py delete mode 100644 scripts/synastry.py create mode 100644 scripts/synastry_full_dossier.py create mode 100644 skills/jyotish-full-synastry-reading/SKILL.md create mode 100644 skills/jyotish-full-synastry-reading/agents/openai.yaml create mode 100644 skills/jyotish-full-synastry-reading/references/chinese-synastry-writing-contract.md create mode 100644 skills/jyotish-full-synastry-reading/references/customer-report-shape.md create mode 100644 skills/jyotish-full-synastry-reading/references/customer-topic-priorities.md create mode 100644 skills/jyotish-full-synastry-reading/references/english-synastry-writing-contract.md create mode 100644 skills/jyotish-full-synastry-reading/references/full-technique-execution-matrix.md create mode 100644 skills/jyotish-full-synastry-reading/references/interaction-progression-evidence-map.md create mode 100644 skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py create mode 100644 skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py create mode 100644 skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py create mode 100644 skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py create mode 100644 skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py create mode 100644 tests/test_marriage_match_api.py create mode 100644 tests/test_marriage_match_product.py create mode 100644 tests/test_synastry_full_dossier.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 09218c61..c5a46724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-18 — 婚恋合盘改为上游七项口径,本单只做计算和存储 + +- 合盘计算改为上游模块:七项、满分 35,两个方向取平均,不按性别比较。火星冲克和婚姻宫压力分开保存。证据包接口会先排两人的完整星盘。 +- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的结果,不扣点。页面和入口留到下一单。 +- 旧的 `synastry` 命令和模块已删除。Skill 版本不变(BUG-1370、BUG-1371、BUG-1372、BUG-1374)。 + ## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额 - 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。 diff --git a/SKILL.md b/SKILL.md index 715b87cd..f38e5397 100644 --- a/SKILL.md +++ b/SKILL.md @@ -351,7 +351,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r - `PyJHora`(AGPL) - JHora(闭源) - `hora-prakash`(AGPL) -- 本仓已落地的 MIT 复用点包括:`kp_system.py`、`synastry.py`、`muhurtha_election.py`、`bhava_bala.py`、`dasha_calculator_enhanced.py`、`jaimini.py`、`constants/mit_imported_constants.py` +- 本仓已落地的 MIT 复用点包括:`kp_system.py`、`ashtakoot.py`、`muhurtha_election.py`、`bhava_bala.py`、`dasha_calculator_enhanced.py`、`jaimini.py`、`constants/mit_imported_constants.py`。`synastry.py` 已退役。 - 继续扩 skill 前,先查 `/docs/research/reuse_license_whitelist_for_skill_2026_06_26.md`,避免重复造轮子或踩许可证边界。 ### Transit 真实过境冻结(v6.0.10-true-transit) @@ -567,7 +567,7 @@ $PYTHON $SCRIPT <子命令> [参数] | `nakshatra-full` | 星宿综合报告(本命 + 大运 + 过境星宿) | | `argala` | Argala门闩系统:主 Argala + Virodhargala + Rajayoga 分类 | | `tajika` | Tajika年运盘(Muntha+YearLord+Mudda Dasha) | -| `synastry` | 合盘分析:Ashta Koota 36分 + Mahendra/Stree Deergha/Vedha/Rajju 等附加Kuta | +| `marriage-match` | 婚恋合盘:两份盘快照,七项 35 分;火星冲克与婚姻宫压力分开列,不按性别比较 | | `report` | MD→HTML报告生成(羊皮纸主题) | | `prashna` | Prashna问事占星 | | `double-transit-pac` | KN Rao Double Transit PAC+D9层 | diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c0e40027..f4c6e4d3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36488,3 +36488,83 @@ - 相关记录:BUG-1361、BUG-1362。 - 复发自:无。 - 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。 + +## BUG-1370 | 合盘结果把本人固定当成男方 + +- 状态:resolved +- 首次发现:2026-10-17 +- 最近更新:2026-10-18 +- 影响面:合盘计算 +- 用户现象:同一对人互换顺序,合盘结果会变;网页路径把本人固定放在男方。 +- 触发条件:用两份出生资料做合盘,或把两人的顺序对调。 +- 根因:旧接口只收两个月亮度数,并写死男方 / 女方。 +- 修复:改用上游合盘模块。配对层对两个方向取平均,不读性别。 +- 验证:上游产品测试与本地对调测试。公开名人证据包可以进入解读。 +- 防复发:对调后配对分、火星冲克和婚姻宫压力一致的测试。 +- 相关记录:BUG-1371。 +- 复发自:无。 +- 修复版本:分支 `codex/marriage-match-p1-20261017`。 + +## BUG-1371 | 网页合盘不算火星冲克 + +- 状态:resolved +- 首次发现:2026-10-17 +- 最近更新:2026-10-18 +- 影响面:合盘计算 +- 用户现象:合盘结果里没有火星冲克。 +- 触发条件:走网页合盘接口。该接口只把两个月亮度数交给计算。 +- 根因:计算函数拿不到整盘,火星冲克字段一直空着。 +- 修复:证据包先做两次完整排盘,再交给上游合盘模块。默认口径和 Raman 口径各算一次并保存。 +- 验证:证据包测试要求公开名人对的结果达到可写解读;快照接口的金样不含旧的五星合分。 +- 防复发:产品测试断言火星冲克不带分数、不按性别比较。 +- 相关记录:BUG-1370、BUG-1333。 +- 复发自:无。 +- 修复版本:分支 `codex/marriage-match-p1-20261017`。 + +## BUG-1372 | 旧合盘模块把互控写死,星宿下标会越界 + +- 状态:resolved +- 首次发现:2026-10-17 +- 最近更新:2026-10-18 +- 影响面:旧合盘模块 +- 用户现象:旧命令算出的互控分不随星盘变化;极端经度可能越界。 +- 触发条件:调用已退役的合盘模块。 +- 根因:互控被写成固定 2.0,星宿下标没有取模。 +- 修复:删除该模块。命令改为 `marriage-match`,只收已经算好的盘快照。附加 Kuta 仍留在 `ashtakoot.py`。 +- 验证:旧合盘模块已删除。开源集成测试改走 `ashtakoot.calculate_ashtakoot`。 +- 防复发:新功能不得调用旧的五星合分函数;这两个函数的说明已标成旧版。 +- 相关记录:BUG-1374。 +- 复发自:无。 +- 修复版本:分支 `codex/marriage-match-p1-20261017`。 + +## BUG-1373 | 合盘结果卡被删掉后只剩一句聊天草稿 + +- 状态:investigating +- 首次发现:2026-10-17 +- 最近更新:2026-10-18 +- 影响面:合盘页面 +- 用户现象:做完合盘看不到结果卡,输入框里只出现一句草稿问题。 +- 触发条件:从档案页点旧的合盘入口。 +- 根因:结果卡随旧面板一起删除,没有新的结果页接上。 +- 修复:本单不改页面。由后续 issue #82 做结果页并改为 resolved。 +- 验证:无。本单没有页面。 +- 防复发:无。留给 #82。 +- 相关记录:BUG-1370。 +- 复发自:无。 +- 修复版本:未修。 + +## BUG-1374 | 文档把 Papasamya 和 Mangal 匹配写成已经实现 + +- 状态:resolved +- 首次发现:2026-10-17 +- 最近更新:2026-10-18 +- 影响面:技能说明、速查表、能力矩阵 +- 用户现象:文档写旧合盘已经覆盖 Papasamya 加权和 Mangal 匹配,代码里没有这套结果。 +- 触发条件:按文档里的旧合盘命令理解合盘能力。 +- 根因:说明跟着旧模块起草,没有对着真实返回核对。 +- 修复:删掉这三处虚报。命令表改为 `marriage-match`:七项满分 35,火星冲克和婚姻宫压力分开列,没有 Papasamya 加权总分。 +- 验证:速查表、能力矩阵和技能命令表已改;旧模块已删除。 +- 防复发:能力矩阵写明旧模块已删除。 +- 相关记录:BUG-1372。 +- 复发自:无。 +- 修复版本:分支 `codex/marriage-match-p1-20261017`。 diff --git a/docs/tasks/PROGRESS-marriage-match-p1-20261017.md b/docs/tasks/PROGRESS-marriage-match-p1-20261017.md new file mode 100644 index 00000000..90e07e0f --- /dev/null +++ b/docs/tasks/PROGRESS-marriage-match-p1-20261017.md @@ -0,0 +1,48 @@ +# 进度:婚恋合盘第一期(2026-10-18) + +## 做了什么 + +按任务书第六版,只做后端。基线 `origin/staging` = `e7bf60c1`。 + +- T1:`scripts/marriage_match.py` 与 `skills/jyotish-full-synastry-reading/` 从 `bde5d615` 副本原样搬入。胶水在 `scripts/synastry_full_dossier.py`:两次 `full-reading` 并行,再补 Raman。 +- T2:`POST /api/marriage-match`、`POST /api/synastry_full_dossier`。证据包在有 `GITHUB_SHA` 时按两人出生资料和提交号做磁盘缓存;没有提交号则不缓存。CLI 命令 `marriage-match`。 +- T3:删除 `scripts/synastry.py` 和 CLI `synastry`。`calc_kuja_dosha` / `match_kuja_dosha` 标为旧版。速查表、能力矩阵、技能命令表去掉 Papasamya / Mangal 已实现的虚报。 +- T4:`synastry_reports` 加人物引用、指纹、规则版本和结构化结果。删档案人物时,引用该人物的合盘行一起删除。旧行不回填。 +- T5:`frontend/src/lib/marriage-match-service.ts`。不扣点,不做路由。 +- T6 / T7 / T8:按第六版不做。 + +## 与上游的差异 + +上游 `marriage_match.py` 和技能目录与副本逐字节相同,没有本地改动。 + +上游测试 `test_api_handler_uses_the_new_product_contract_without_legacy_kuja_score`: + +| | | +| --- | --- | +| 原值 | `JyotishAPIHandler._compute_marriage_match` | +| 新值 | `marriage_match_api.compute_marriage_match` | +| 原因 | 类方法数不得增长,计算放在独立模块 | + +`tests/test_open_source_integrations.py` 的附加 Kuta 测试同样改走 `ashtakoot.calculate_ashtakoot`,因为 `synastry.py` 已删除。 + +## 证据包截取 + +`frontend/tests/fixtures/synastry-full-dossier-truncated.json` 只留:schema、`delivery_readiness`、默认 profile、国际核心合计与满分、Raman profile、两边行星名。度数和模块正文不进金样。`marriage-match-snapshot.json` 是快照接口的完整引擎输出(虚构度数,不是出生资料)。 + +## 命令与结果 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 开工预检 | `python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` | 远端可见。碎片扫描 90 秒超时,聚焦测试 45 秒超时。未据此宣称通过。 | +| 合盘单测 | `pytest tests/test_marriage_match_product.py tests/test_marriage_match_api.py tests/test_open_source_integrations.py tests/test_ashtakoot.py tests/test_api_server_growth_contract.py tests/test_api_heavy_compute_gate.py tests/test_synastry_full_dossier.py` | 产品测试、缓存、增长合同、公开名人证据包通过。证据包测试约 27 秒,`delivery_readiness = ready_for_narrative`。 | +| 服务端函数 | `node node_modules/tsx/dist/cli.mjs --test tests/marriage-match-service.test.ts` | 6 通过,0 失败,0 取消。 | +| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 0 错。 | +| lint | `npm run lint`(frontend) | 0 error,123 warning(原有 warning)。 | +| 数据库 | `npm run test:db` | 86 项:73 通过,13 失败,0 取消。13 条失败名称与 `tools/agent-loop/windows-known-failures.txt` 逐条相同。婚恋合盘这一项通过。 | +| 全量 pytest / 快速门 | 未跑 | Windows 执行约定由验收方跑。 | + +## 偏离 + +- 不做页面、不扣点,与第六版一致。 +- 类方法不能增加,所以没有把 `_compute_marriage_match` 加回 `JyotishAPIHandler`。 +- 预检的两个超时是本机限时,不是计算失败。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 7352f2c9..3b01e898 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -501,7 +501,7 @@ | `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | | `TASK-people-archive-limit-20-20261017.md` | — | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待领取 | `codex/people-archive-limit-20-20261017`;动表,需 test:db | -| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待执行(不再等 #78) | `codex/marriage-match-p1-20261017`;动表 | +| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待验收 | `codex/marriage-match-p1-20261017`;动表 | | `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` | | `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 | | `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 | diff --git a/frontend/src/lib/marriage-match-service.ts b/frontend/src/lib/marriage-match-service.ts new file mode 100644 index 00000000..08642ad8 --- /dev/null +++ b/frontend/src/lib/marriage-match-service.ts @@ -0,0 +1,241 @@ +/** + * Server-side marriage-match lookup. No route and no credit charge. + * The same unordered pair, birth fingerprints, and rules version reuse the + * stored engine packet. + */ + +import { createHash } from "node:crypto"; + +import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts"; +import { + ConsultationSubjectError, + loadSubjectBirth, + subjectIdFromQuery, + type ResolvedSubjectBirth, +} from "./subject-birth.ts"; + +export const MARRIAGE_MATCH_RULES_VERSION = "bde5d615:marriage_match_product_v1"; + +export class MarriageMatchServiceError extends Error { + readonly code: "same_person" | "subject_incomplete" | "engine_failed" | "store_failed"; + + constructor(code: "same_person" | "subject_incomplete" | "engine_failed" | "store_failed") { + super(code); + this.name = "MarriageMatchServiceError"; + this.code = code; + } +} + +type EqQuery = { + eq: (column: string, value: string) => EqQuery; + maybeSingle: () => Promise<{ data: unknown; error: unknown }>; +}; + +export type MarriageMatchDb = { + from: (table: string) => { + select: (columns: string) => EqQuery; + insert: (row: Record) => Promise<{ error: unknown }>; + }; +}; + +export type MarriageMatchPacket = { + rules_version: string; + dossier: unknown; + raman_marriage_match: unknown; +}; + +export type MarriageMatchRead = { + rulesVersion: string; + cached: boolean; + result: MarriageMatchPacket; +}; + +type BirthBody = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + lat: number; + lon: number; + tz: number; + ayanamsa: string; + node_mode: "mean"; + today: string; +}; + +function engineBirth(row: Record, today: string): BirthBody { + const profile = globalBirthProfileFromAccountRow(row); + const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? ""); + const timeMatch = /^(\d{2}):(\d{2})/.exec(profile.time ?? ""); + const latitude = profile.latitude; + const longitude = profile.longitude; + const timezoneOffset = profile.timezoneOffset; + const ayanamsa = profile.ayanamsa; + if ( + !dateMatch + || !timeMatch + || latitude == null + || longitude == null + || timezoneOffset == null + || !ayanamsa + ) { + throw new MarriageMatchServiceError("subject_incomplete"); + } + return { + year: Number(dateMatch[1]), + month: Number(dateMatch[2]), + day: Number(dateMatch[3]), + hour: Number(timeMatch[1]), + minute: Number(timeMatch[2]), + second: 0, + lat: latitude, + lon: longitude, + tz: timezoneOffset, + ayanamsa, + node_mode: "mean", + today, + }; +} + +export function marriageMatchFingerprint(row: Record): string { + const birth = engineBirth(row, "1970-01-01"); + const stable = { + year: birth.year, + month: birth.month, + day: birth.day, + hour: birth.hour, + minute: birth.minute, + second: birth.second, + lat: birth.lat, + lon: birth.lon, + tz: birth.tz, + ayanamsa: birth.ayanamsa, + node_mode: birth.node_mode, + }; + return createHash("sha256").update(JSON.stringify(stable)).digest("hex"); +} + +function canonicalPair( + leftId: string, + rightId: string, + leftFingerprint: string, + rightFingerprint: string, +) { + if (leftId <= rightId) { + return { + subjectLow: leftId, + subjectHigh: rightId, + fingerprintLow: leftFingerprint, + fingerprintHigh: rightFingerprint, + }; + } + return { + subjectLow: rightId, + subjectHigh: leftId, + fingerprintLow: rightFingerprint, + fingerprintHigh: leftFingerprint, + }; +} + +function isPacket(value: unknown): value is MarriageMatchPacket { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return typeof record.rules_version === "string" && "dossier" in record && "raman_marriage_match" in record; +} + +async function findSaved( + db: MarriageMatchDb, + userId: string, + pair: ReturnType, +): Promise { + const { data, error } = await db + .from("synastry_reports") + .select("structured_result") + .eq("user_id", userId) + .eq("subject_low", pair.subjectLow) + .eq("subject_high", pair.subjectHigh) + .eq("fingerprint_low", pair.fingerprintLow) + .eq("fingerprint_high", pair.fingerprintHigh) + .eq("rules_version", MARRIAGE_MATCH_RULES_VERSION) + .maybeSingle(); + if (error) throw new MarriageMatchServiceError("store_failed"); + if (!data || typeof data !== "object") return null; + const stored = (data as { structured_result?: unknown }).structured_result; + return isPacket(stored) ? stored : null; +} + +export async function readOrComputeMarriageMatch(input: { + supabase: MarriageMatchDb; + userId: string; + personAId: string; + personBId: string; + postDossier: (body: { person_a: BirthBody; person_b: BirthBody }) => Promise; + today?: string; +}): Promise { + const personAId = subjectIdFromQuery(input.personAId); + const personBId = subjectIdFromQuery(input.personBId); + if (personAId === personBId) throw new MarriageMatchServiceError("same_person"); + + const [personA, personB] = await Promise.all([ + loadSubjectBirth(input.supabase, input.userId, personAId), + loadSubjectBirth(input.supabase, input.userId, personBId), + ]); + return readResolvedPair({ + supabase: input.supabase, + userId: input.userId, + personA, + personB, + postDossier: input.postDossier, + today: input.today ?? new Date().toISOString().slice(0, 10), + }); +} + +async function readResolvedPair(input: { + supabase: MarriageMatchDb; + userId: string; + personA: ResolvedSubjectBirth; + personB: ResolvedSubjectBirth; + postDossier: (body: { person_a: BirthBody; person_b: BirthBody }) => Promise; + today: string; +}): Promise { + const pair = canonicalPair( + input.personA.subjectId, + input.personB.subjectId, + marriageMatchFingerprint(input.personA.row), + marriageMatchFingerprint(input.personB.row), + ); + const saved = await findSaved(input.supabase, input.userId, pair); + if (saved) { + return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: true, result: saved }; + } + + let computed: unknown; + try { + computed = await input.postDossier({ + person_a: engineBirth(input.personA.row, input.today), + person_b: engineBirth(input.personB.row, input.today), + }); + } catch (error) { + if (error instanceof MarriageMatchServiceError || error instanceof ConsultationSubjectError) throw error; + throw new MarriageMatchServiceError("engine_failed"); + } + if (!isPacket(computed) || computed.rules_version !== MARRIAGE_MATCH_RULES_VERSION) { + throw new MarriageMatchServiceError("engine_failed"); + } + + const inserted = await input.supabase.from("synastry_reports").insert({ + user_id: input.userId, + partner_name: "婚恋合盘", + report: computed, + subject_low: pair.subjectLow, + subject_high: pair.subjectHigh, + fingerprint_low: pair.fingerprintLow, + fingerprint_high: pair.fingerprintHigh, + rules_version: MARRIAGE_MATCH_RULES_VERSION, + structured_result: computed, + }); + if (inserted.error) throw new MarriageMatchServiceError("store_failed"); + return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: false, result: computed }; +} diff --git a/frontend/supabase/migrations/20261018010000_synastry_reports_marriage_match.sql b/frontend/supabase/migrations/20261018010000_synastry_reports_marriage_match.sql new file mode 100644 index 00000000..a3e720cb --- /dev/null +++ b/frontend/supabase/migrations/20261018010000_synastry_reports_marriage_match.sql @@ -0,0 +1,67 @@ +begin; + +-- Marriage-match rows store two subject refs, birth fingerprints, the rule +-- version, and the structured engine result. Older rows stay null and are not +-- backfilled. Deleting a saved person removes every match row that cites them. + +alter table public.synastry_reports + add column if not exists subject_low text, + add column if not exists subject_high text, + add column if not exists fingerprint_low text, + add column if not exists fingerprint_high text, + add column if not exists rules_version text, + add column if not exists structured_result jsonb; + +alter table public.synastry_reports + drop constraint if exists synastry_reports_structured_result_object; + +alter table public.synastry_reports + add constraint synastry_reports_structured_result_object + check (structured_result is null or jsonb_typeof(structured_result) = 'object'); + +create unique index if not exists synastry_reports_pair_fingerprint_uidx + on public.synastry_reports ( + user_id, + subject_low, + subject_high, + fingerprint_low, + fingerprint_high, + rules_version + ) + where subject_low is not null + and subject_high is not null + and fingerprint_low is not null + and fingerprint_high is not null + and rules_version is not null; + +create or replace function public.delete_synastry_reports_for_chart_subject() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + delete from public.synastry_reports + where subject_low = old.id::text + or subject_high = old.id::text; + return old; +end; +$$; + +revoke all on function public.delete_synastry_reports_for_chart_subject() from public, anon, authenticated; + +drop trigger if exists chart_profiles_delete_synastry_reports on public.chart_profiles; +create trigger chart_profiles_delete_synastry_reports +after delete on public.chart_profiles +for each row execute function public.delete_synastry_reports_for_chart_subject(); + +grant insert ( + subject_low, + subject_high, + fingerprint_low, + fingerprint_high, + rules_version, + structured_result +) on table public.synastry_reports to authenticated; + +commit; diff --git a/frontend/tests/database-marriage-match.test.ts b/frontend/tests/database-marriage-match.test.ts new file mode 100644 index 00000000..3c80faea --- /dev/null +++ b/frontend/tests/database-marriage-match.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const userId = "93000000-0000-4000-8000-000000000079"; +const personId = "93000000-0000-4000-8000-000000000179"; +const fingerprint = "a".repeat(64); +const otherFingerprint = "b".repeat(64); +const rulesVersion = "bde5d615:marriage_match_product_v1"; + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0; +} + +function lastLine(value: string): string { + const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +test("marriage match rows are reused by fingerprint and removed with the person", { + skip: dockerAvailable() ? false : "docker unavailable on this host", +}, () => { + const fixture = startPostgresFixture(); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, + }); + assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`); + assert.match(migration.stdout ?? "", /applied 20261018010000_synastry_reports_marriage_match\.sql/); + + fixture.psql(` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${userId}', 'Fictional Match', '${userId}@example.invalid', true, now()); + insert into public.chart_profiles (id, user_id, role, profile) + values ('${personId}', '${userId}', 'other', '{}'::jsonb); + insert into public.synastry_reports ( + user_id, partner_name, report, + subject_low, subject_high, fingerprint_low, fingerprint_high, + rules_version, structured_result + ) values ( + '${userId}', '婚恋合盘', '{"rules_version":"${rulesVersion}","dossier":{},"raman_marriage_match":{}}'::jsonb, + '${personId}', 'self', '${fingerprint}', '${otherFingerprint}', + '${rulesVersion}', '{"rules_version":"${rulesVersion}","dossier":{},"raman_marriage_match":{}}'::jsonb + ); + `); + + const again = lastLine(fixture.psql(` + select count(*) from public.synastry_reports + where user_id = '${userId}' + and subject_low = '${personId}' + and subject_high = 'self' + and fingerprint_low = '${fingerprint}' + and fingerprint_high = '${otherFingerprint}' + and rules_version = '${rulesVersion}'; + `)); + assert.equal(again, "1"); + + const changed = lastLine(fixture.psql(` + select count(*) from public.synastry_reports + where user_id = '${userId}' + and subject_low = '${personId}' + and fingerprint_low = '${"c".repeat(64)}' + and rules_version = '${rulesVersion}'; + `)); + assert.equal(changed, "0"); + + fixture.psql(`delete from public.chart_profiles where id = '${personId}';`); + const remaining = lastLine(fixture.psql(` + select count(*) from public.synastry_reports where user_id = '${userId}'; + `)); + assert.equal(remaining, "0"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/fixtures/marriage-match-snapshot.json b/frontend/tests/fixtures/marriage-match-snapshot.json new file mode 100644 index 00000000..fff19d39 --- /dev/null +++ b/frontend/tests/fixtures/marriage-match-snapshot.json @@ -0,0 +1,562 @@ +{ + "product": "marriage_match_product_v1", + "relationship_type": "romantic_or_marriage", + "rule_profile": { + "id": "practitioner_consensus_v1", + "label": "Practitioner consensus v1", + "notes": [ + "Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.", + "Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.", + "Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.", + "Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion." + ], + "gender_asymmetry_used": false + }, + "ashtakoota_36": { + "status": "executed", + "international_core": { + "method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)", + "scores": { + "Vashya": 0.0, + "Tara": 1.5, + "Yoni": 2.0, + "GrahaMaitri": 3.0, + "Gana": 0.0, + "Bhakoot": 7.0, + "Nadi": 0.0 + }, + "total_score": 13.5, + "max_score": 35.0, + "match_percentage": 38.6, + "person_a_details": { + "moon_sign": "Taurus", + "nakshatra": "Rohini", + "nakshatra_pada": 3 + }, + "person_b_details": { + "moon_sign": "Scorpio", + "nakshatra": "Vishakha", + "nakshatra_pada": 3 + }, + "directionality": { + "status": "symmetric", + "rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a" + }, + "boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict." + }, + "shared_life_dialogue": { + "status": "not_scored", + "storage": "no_answers_collected", + "prompts": [ + "What does a committed relationship mean to each of you?", + "How do you want to make decisions about money, home, and care responsibilities?", + "What boundaries, privacy expectations, and conflict-repair habits matter most?", + "How do you each imagine work, family, location, and personal autonomy over the next few years?" + ], + "boundary": "These are voluntary discussion prompts, not a compatibility test or a score." + }, + "classical_varna": { + "status": "excluded_from_default", + "reason": "Historical caste hierarchy is not used in the international product or its default score." + }, + "person_a": { + "moon_sign": "Taurus", + "nakshatra": { + "index": 3, + "name": "Rohini", + "lord": "Moon", + "pada": 3 + } + }, + "person_b": { + "moon_sign": "Scorpio", + "nakshatra": { + "index": 15, + "name": "Vishakha", + "lord": "Jupiter", + "pada": 3 + } + }, + "exception_review": { + "bhakoot": { + "raw_score": 7.0, + "candidate_cancellations": [], + "status": "none" + }, + "nadi": { + "raw_score": 0.0, + "candidate_cancellations": [], + "status": "none" + }, + "yoni": { + "raw_score": 2.0, + "status": "legacy_three_band_table", + "boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score." + } + } + }, + "kuja_mangal_dosha": { + "comparison_method": "neutral_reference_count", + "gender_asymmetry_used": false, + "outcome": "broadly_balanced", + "person_a": { + "system": "Kuja / Mangal Dosha", + "scope": "Mars only", + "profile_id": "practitioner_consensus_v1", + "status": "executed", + "active_reference_count": 2, + "cancelled_reference_count": 0, + "qualitative_level": "material", + "frames": [ + { + "reference": "lagna", + "reference_sign": "Aries", + "mars_sign": "Libra", + "mars_house": 7, + "raw_signal": true, + "status": "active", + "mitigations": [] + }, + { + "reference": "moon", + "reference_sign": "Taurus", + "mars_sign": "Libra", + "mars_house": 6, + "raw_signal": false, + "status": "not_triggered", + "mitigations": [] + }, + { + "reference": "venus", + "reference_sign": "Cancer", + "mars_sign": "Libra", + "mars_house": 4, + "raw_signal": true, + "status": "active", + "mitigations": [] + } + ], + "boundary": "Reference count is a presentation label, not a probability or a compatibility score." + }, + "person_b": { + "system": "Kuja / Mangal Dosha", + "scope": "Mars only", + "profile_id": "practitioner_consensus_v1", + "status": "executed", + "active_reference_count": 2, + "cancelled_reference_count": 0, + "qualitative_level": "material", + "frames": [ + { + "reference": "lagna", + "reference_sign": "Leo", + "mars_sign": "Aquarius", + "mars_house": 7, + "raw_signal": true, + "status": "active", + "mitigations": [ + "leo_aquarius_exception_disputed" + ] + }, + { + "reference": "moon", + "reference_sign": "Scorpio", + "mars_sign": "Aquarius", + "mars_house": 4, + "raw_signal": true, + "status": "active", + "mitigations": [ + "leo_aquarius_exception_disputed" + ] + }, + { + "reference": "venus", + "reference_sign": "Taurus", + "mars_sign": "Aquarius", + "mars_house": 10, + "raw_signal": false, + "status": "not_triggered", + "mitigations": [] + } + ], + "boundary": "Reference count is a presentation label, not a probability or a compatibility score." + }, + "boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation." + }, + "papasamya": { + "person_a": { + "system": "Papasamya pressure", + "scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.", + "profile_id": "practitioner_consensus_v1", + "status": "executed", + "planet_rows": [ + { + "planet": "Mars", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 7, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 6, + "in_pressure_house": false + }, + { + "reference": "venus", + "house": 4, + "in_pressure_house": true + } + ] + }, + { + "planet": "Saturn", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 8, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 7, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 5, + "in_pressure_house": false + } + ] + }, + { + "planet": "Sun", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 5, + "in_pressure_house": false + }, + { + "reference": "moon", + "house": 4, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 2, + "in_pressure_house": true + } + ] + }, + { + "planet": "Rahu", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 11, + "in_pressure_house": false + }, + { + "reference": "moon", + "house": 10, + "in_pressure_house": false + }, + { + "reference": "venus", + "house": 8, + "in_pressure_house": true + } + ] + }, + { + "planet": "Ketu", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 5, + "in_pressure_house": false + }, + { + "reference": "moon", + "house": 4, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 2, + "in_pressure_house": true + } + ] + } + ], + "active_placements": [ + { + "planet": "Mars", + "reference": "lagna", + "house": 7 + }, + { + "planet": "Mars", + "reference": "venus", + "house": 4 + }, + { + "planet": "Saturn", + "reference": "lagna", + "house": 8 + }, + { + "planet": "Saturn", + "reference": "moon", + "house": 7 + }, + { + "planet": "Sun", + "reference": "moon", + "house": 4 + }, + { + "planet": "Sun", + "reference": "venus", + "house": 2 + }, + { + "planet": "Rahu", + "reference": "venus", + "house": 8 + }, + { + "planet": "Ketu", + "reference": "moon", + "house": 4 + }, + { + "planet": "Ketu", + "reference": "venus", + "house": 2 + } + ], + "boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score." + }, + "person_b": { + "system": "Papasamya pressure", + "scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.", + "profile_id": "practitioner_consensus_v1", + "status": "executed", + "planet_rows": [ + { + "planet": "Mars", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 7, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 4, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 10, + "in_pressure_house": false + } + ] + }, + { + "planet": "Saturn", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 4, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 1, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 7, + "in_pressure_house": true + } + ] + }, + { + "planet": "Sun", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 1, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 10, + "in_pressure_house": false + }, + { + "reference": "venus", + "house": 4, + "in_pressure_house": true + } + ] + }, + { + "planet": "Rahu", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 7, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 4, + "in_pressure_house": true + }, + { + "reference": "venus", + "house": 10, + "in_pressure_house": false + } + ] + }, + { + "planet": "Ketu", + "status": "executed", + "frames": [ + { + "reference": "lagna", + "house": 1, + "in_pressure_house": true + }, + { + "reference": "moon", + "house": 10, + "in_pressure_house": false + }, + { + "reference": "venus", + "house": 4, + "in_pressure_house": true + } + ] + } + ], + "active_placements": [ + { + "planet": "Mars", + "reference": "lagna", + "house": 7 + }, + { + "planet": "Mars", + "reference": "moon", + "house": 4 + }, + { + "planet": "Saturn", + "reference": "lagna", + "house": 4 + }, + { + "planet": "Saturn", + "reference": "moon", + "house": 1 + }, + { + "planet": "Saturn", + "reference": "venus", + "house": 7 + }, + { + "planet": "Sun", + "reference": "lagna", + "house": 1 + }, + { + "planet": "Sun", + "reference": "venus", + "house": 4 + }, + { + "planet": "Rahu", + "reference": "lagna", + "house": 7 + }, + { + "planet": "Rahu", + "reference": "moon", + "house": 4 + }, + { + "planet": "Ketu", + "reference": "lagna", + "house": 1 + }, + { + "planet": "Ketu", + "reference": "venus", + "house": 4 + } + ], + "boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score." + } + }, + "relationship_structure": { + "person_a": { + "status": "not_available", + "supplied_layers": [], + "required_for_complete_reading": [ + "D1 relationship structure", + "D9", + "UL", + "DK", + "relationship timing / Dasha" + ], + "boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review." + }, + "person_b": { + "status": "not_available", + "supplied_layers": [], + "required_for_complete_reading": [ + "D1 relationship structure", + "D9", + "UL", + "DK", + "relationship timing / Dasha" + ], + "boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review." + } + }, + "presentation": { + "headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.", + "decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.", + "not_in_scope": [ + "business partnerships", + "family matching", + "age-based cancellation", + "sex-asymmetric tolerance" + ] + }, + "audit": { + "kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied", + "papasamya": "executed separately: five-planet pressure placement review", + "ashtakoota": "executed", + "d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated" + } +} diff --git a/frontend/tests/fixtures/synastry-full-dossier-truncated.json b/frontend/tests/fixtures/synastry-full-dossier-truncated.json new file mode 100644 index 00000000..aeddd557 --- /dev/null +++ b/frontend/tests/fixtures/synastry-full-dossier-truncated.json @@ -0,0 +1,30 @@ +{ + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "default_profile": "practitioner_consensus_v1", + "total_score": 19.0, + "max_score": 35.0, + "raman_profile": "raman_muhurtha_v1", + "planets_a": [ + "Jupiter", + "Ketu", + "Mars", + "Mercury", + "Moon", + "Rahu", + "Saturn", + "Sun", + "Venus" + ], + "planets_b": [ + "Jupiter", + "Ketu", + "Mars", + "Mercury", + "Moon", + "Rahu", + "Saturn", + "Sun", + "Venus" + ] +} diff --git a/frontend/tests/marriage-match-service.test.ts b/frontend/tests/marriage-match-service.test.ts new file mode 100644 index 00000000..6355fba0 --- /dev/null +++ b/frontend/tests/marriage-match-service.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { ConsultationSubjectError } from "../src/lib/subject-birth.ts"; +import { + MARRIAGE_MATCH_RULES_VERSION, + MarriageMatchServiceError, + marriageMatchFingerprint, + readOrComputeMarriageMatch, + type MarriageMatchDb, + type MarriageMatchPacket, +} from "../src/lib/marriage-match-service.ts"; + +const userId = "user-1"; +const otherId = "11111111-1111-4111-8111-111111111111"; + +const selfRow = { + name: "虚构甲", + birth_date: "1991-04-07", + reported_birth_time: "09:15:00", + birth_time_status: "reported", + latitude: 31.2, + longitude: 121.4, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "lahiri", +}; + +const otherRow = { + id: otherId, + user_id: userId, + role: "other", + name: "虚构乙", + birth_date: "1988-03-04", + reported_birth_time: "07:40:00", + birth_time_status: "reported", + latitude: 22.3, + longitude: 114.1, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "raman", +}; + +const packet: MarriageMatchPacket = { + rules_version: MARRIAGE_MATCH_RULES_VERSION, + dossier: { schema: "fixture" }, + raman_marriage_match: { product: "marriage_match_product_v1" }, +}; + +function createDb(other = otherRow) { + const saved: Record[] = []; + const db: MarriageMatchDb = { + from(table) { + return { + select() { + const filters: [string, string][] = []; + const query = { + eq(column: string, value: string) { + filters.push([column, value]); + return query; + }, + async maybeSingle() { + if (table === "profiles") return { data: selfRow, error: null }; + if (table === "chart_profiles") { + const id = filters.find(([column]) => column === "id")?.[1]; + const owner = filters.find(([column]) => column === "user_id")?.[1]; + const row = id === other.id && owner === other.user_id ? other : null; + return { data: row, error: null }; + } + const match = saved.find((row) => filters.every(([column, value]) => row[column] === value)); + return { + data: match ? { structured_result: match.structured_result } : null, + error: null, + }; + }, + }; + return query; + }, + async insert(row) { + saved.push(row); + return { error: null }; + }, + }; + }, + }; + return { db, saved }; +} + +test("a saved pair is returned without calling the engine", async () => { + const { db, saved } = createDb(); + saved.push({ + user_id: userId, + subject_low: otherId, + subject_high: "self", + fingerprint_low: marriageMatchFingerprint(otherRow), + fingerprint_high: marriageMatchFingerprint(selfRow), + rules_version: MARRIAGE_MATCH_RULES_VERSION, + structured_result: packet, + }); + let calls = 0; + const read = await readOrComputeMarriageMatch({ + supabase: db, + userId, + personAId: "self", + personBId: otherId, + postDossier: async () => { + calls += 1; + return packet; + }, + }); + assert.equal(calls, 0); + assert.equal(read.cached, true); + assert.equal(read.result, packet); +}); + +test("the same person twice is rejected", async () => { + const { db } = createDb(); + await assert.rejects( + () => readOrComputeMarriageMatch({ + supabase: db, + userId, + personAId: "self", + personBId: "self", + postDossier: async () => packet, + }), + (error: unknown) => error instanceof MarriageMatchServiceError && error.code === "same_person", + ); +}); + +test("a person who is not in this account is rejected", async () => { + const { db } = createDb(); + await assert.rejects( + () => readOrComputeMarriageMatch({ + supabase: db, + userId, + personAId: "self", + personBId: "22222222-2222-4222-8222-222222222222", + postDossier: async () => packet, + }), + (error: unknown) => error instanceof ConsultationSubjectError && error.code === "subject_not_found", + ); +}); + +test("swapped ids hit the same stored row", async () => { + const { db, saved } = createDb(); + let calls = 0; + const input = { + supabase: db, + userId, + postDossier: async () => { + calls += 1; + return packet; + }, + }; + const first = await readOrComputeMarriageMatch({ ...input, personAId: "self", personBId: otherId }); + const second = await readOrComputeMarriageMatch({ ...input, personAId: otherId, personBId: "self" }); + assert.equal(calls, 1); + assert.equal(first.cached, false); + assert.equal(second.cached, true); + assert.equal(saved.length, 1); + assert.equal(saved[0]?.subject_low, otherId); + assert.equal(saved[0]?.subject_high, "self"); +}); + +test("a changed birth fingerprint is not the stored row", async () => { + const changed = { ...otherRow, birth_date: "1988-03-05" }; + assert.notEqual(marriageMatchFingerprint(otherRow), marriageMatchFingerprint(changed)); +}); + +test("the service does not charge usage", () => { + const source = readFileSync(new URL("../src/lib/marriage-match-service.ts", import.meta.url), "utf8"); + assert.equal(source.includes("authorizeUsage"), false); +}); diff --git a/references/quick-reference-guide.md b/references/quick-reference-guide.md index 31e6973c..e7c430df 100644 --- a/references/quick-reference-guide.md +++ b/references/quick-reference-guide.md @@ -368,7 +368,7 @@ SCRIPT=~/.workbuddy/skills/jyotish-vedic-astrology/scripts/jyotish_engine.py | `nakshatra-full` | 星宿综合报告(本命星宿力量 + 星宿大运 + 过境星宿叠加) | `nakshatra-full --year 1990 --month 1 --day 1 --hour 12 --minute 0 --lat 39.9 --lon 116.4 --tz 8 --age 33` | | `argala` | Argala门闩系统(主 Argala + Virodhargala + 次级/特殊 Argala + Rajayoga 分类) | `argala --year 1990 --month 1 --day 1 --hour 12 --minute 0 --lat 39.9 --lon 116.4 --tz 8` | | `tajika` | Tajika年运盘(Muntha + YearLord + Mudda Dasha + Tri-Pataka) | `tajika --year 1990 --month 1 --day 1 --hour 12 --minute 0 --lat 39.9 --lon 116.4 --tz 8 --age 33` | -| `synastry` | 合盘分析(Ashta Koota 36分 + Mangal Dosha + Papasamya + Mahendra/Stree Deergha/Vedha/Rajju附加Kuta) | `synastry --moon1 310.89 --moon2 45.5 --mars1 90.43 --mars2 120.3` | +| `marriage-match` | 婚恋合盘(两份已算好的盘快照;七项满分 35,火星冲克与婚姻宫压力分开列出,没有 Papasamya 加权总分) | `marriage-match --person-a a.json --person-b b.json` | ### 评估与验证命令 diff --git a/references/technique-capability-matrix.md b/references/technique-capability-matrix.md index 4e030c31..92cad0e7 100644 --- a/references/technique-capability-matrix.md +++ b/references/technique-capability-matrix.md @@ -39,7 +39,7 @@ | Bhava Chalit | Yes | Yes | Yes | Yes | `covered` | v6.1.10: `scripts/bhava_chalit.py` 完整实现(等宫制从Lagna中点划分,跨宫检测,Cusp/Madhya计算)。代码基于dashaflow(MIT)。 | | Sudarshana Chakra | Yes | Yes | Yes | Yes | `covered` | v6.1.10: `scripts/sudarshana_chakra.py` 完整实现(三轮盘三环汇聚 + D1×D9×D10三角分析 + 12年周期大运)。参考PyJHora思路自行编码。 | | KP / Sub-lord | Yes | Yes | Yes | Yes | `covered` | KP references and sub-lord calculations exist. | -| Synastry / Ashta Koota + Additional Kutas | Yes | Yes | Yes | Yes | `covered` | v6.1.11: `synastry.py` 在 Ashta Koota 36分、Mangal Dosha、Papasamya 基础上新增 Mahendra、Stree Deergha、Vedha、Rajju、BadConstellations 与例外减弱规则,输出 `additional_kutas`。 | +| Synastry / Ashta Koota + Additional Kutas | Yes | Yes | Yes | Yes | `covered` | 婚恋合盘走 `marriage_match.py`:默认七项、满分 35,火星冲克只看火星,婚姻宫压力单独列出,没有 Papasamya 加权总分。附加 Kuta 仍在 `ashtakoot.py`。旧 `synastry.py` 已删除。 | | Tajika / Varshaphala | Yes | Yes | Yes | Yes | `covered` | `tajika` and `full-reading`. | | Double Transit | Yes | Yes | Yes | Yes | `covered` | `double-transit-pac`, transit multi-reference. | diff --git a/references/technique_registry.json b/references/technique_registry.json index 0a4e12aa..30556909 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -2600,9 +2600,9 @@ "version": "6.9.3" }, "synastry_16factor": { - "audit_label": "synastry.py", + "audit_label": "marriage_match.py", "commands": [ - "synastry" + "marriage-match" ], "conclusion_policy": "May participate in a domain primary chain; do not use as a single-factor conclusion.", "domains": [ @@ -2612,10 +2612,10 @@ "evidence_role": "primary", "knowledge_refs": [], "missing_impact": "covered", - "name": "synastry.py", - "note": "synastry.py — Ashtakoot 8因子36分制+Kuta+附加因子 (dashaflow MIT)", + "name": "marriage_match.py", + "note": "marriage_match.py — seven-kuta international core (max 35). Mars-only Kuja and Papasamya pressure are separate layers; synastry.py is retired.", "output_paths": [ - "scripts/synastry.py" + "scripts/marriage_match.py" ], "status": "covered", "user_visibility": "ordinary_topic_router", diff --git a/scripts/api_heavy_compute_gate.py b/scripts/api_heavy_compute_gate.py index 5789cc6f..6dedfa1a 100644 --- a/scripts/api_heavy_compute_gate.py +++ b/scripts/api_heavy_compute_gate.py @@ -45,6 +45,7 @@ HEAVY_COMPUTE_PATHS = frozenset( "/api/vedastro/range_scan", "/api/vedastro_gateway/run", "/api/thematic_report", + "/api/synastry_full_dossier", "/api/qizheng", "/api/western", "/api/ephemeris_events", diff --git a/scripts/api_scratch_retention.py b/scripts/api_scratch_retention.py index 126eb792..e44aac6e 100644 --- a/scripts/api_scratch_retention.py +++ b/scripts/api_scratch_retention.py @@ -44,6 +44,7 @@ OFFLINE_SCRATCH_NAMES = frozenset( "consult_full_data_cache", # 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。 "report_reference_cache", + "synastry_full_dossier_cache", "ocr_extract", "three_engine_parity", "public_release_deny_patterns.txt", diff --git a/scripts/ashtakoot.py b/scripts/ashtakoot.py index 7b8c6acb..2a295aa7 100644 --- a/scripts/ashtakoot.py +++ b/scripts/ashtakoot.py @@ -364,7 +364,7 @@ def _planet_dignity_level(planet_name: str, sign: str) -> str: def calc_kuja_dosha(chart: Dict) -> Dict: - """计算完整的 Kuja Dosha(火星凶相分析)""" + """旧版 Kuja Dosha(五星合分)。新功能不得调用;婚恋合盘用 marriage_match。""" planets = chart.get("planets", {}) dosha_scores_high = { @@ -408,6 +408,7 @@ def calc_kuja_dosha(chart: Dict) -> Dict: def match_kuja_dosha(male_score: float, female_score: float) -> Dict: + """旧版:按性别比较火星冲克分数。新功能不得调用。""" diff = male_score - female_score if abs(diff) <= 5: return {"result": "good", "description": "Kuja Dosha balanced."} diff --git a/scripts/compatibility_skill_readiness_dashboard.py b/scripts/compatibility_skill_readiness_dashboard.py index 33a3f60d..2a8a7ad2 100644 --- a/scripts/compatibility_skill_readiness_dashboard.py +++ b/scripts/compatibility_skill_readiness_dashboard.py @@ -61,7 +61,7 @@ def build() -> dict: "partial", "basic_safe_with_boundary", "36-point compatibility can be exposed as one factor only; not deterministic marriage outcome.", - ["references/oracle/ashtakoot_oracle_cases.json", "scripts/synastry.py"], + ["references/oracle/ashtakoot_oracle_cases.json", "scripts/marriage_match.py"], ), layer( "mangal_dosha", diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 908d7796..47e50517 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -10505,6 +10505,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService): elif path == '/api/synastry': result = self._compute_synastry(body) self._json(result) + elif path == '/api/marriage-match': + match_api = _load_local_module('marriage_match_api') + try: + result = match_api.compute_marriage_match(body) + except match_api.MarriageMatchInputError as exc: + raise BadRequest(str(exc)) from exc + self._json(result) + elif path == '/api/synastry_full_dossier': + match_api = _load_local_module('marriage_match_api') + try: + result = match_api.compute_synastry_full_dossier(self, body) + except match_api.MarriageMatchInputError as exc: + raise BadRequest(str(exc)) from exc + self._json(result) elif path == '/api/dasha': result = self._compute_dasha_system(body) self._json(result) diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index f66aa24a..a594411d 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -16,7 +16,7 @@ nakshatra-adv 高级Nakshatra分析(Tara Bala/Sub-Lord/兼容性)(v3.7新增) argala Argala门闩系统(v3.7新增) tajika Tajika/Varshaphala年运盘(v3.7新增) - synastry 合盘分析(Ashta Koota 36分制+Mangal Dosha)(v3.7新增) + marriage-match 婚恋合盘(两份盘快照;七项 35 分,火星冲克与婚姻宫压力分开列) full-reading 全自动综合解盘(出生信息→全链路计算→完整报告)(v3.7.1新增) celebrity 名人案例查询(15,807条数据+SQLite验证库) db-stats 验证数据库统计 @@ -14499,20 +14499,17 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'): # ============================================================================ # 21. 合盘分析(v3.7新增) # ============================================================================ -def cmd_synastry(args): +def cmd_marriage_match(args): try: sys.path.insert(0, SCRIPT_DIR) - from synastry import calc_synastry - except ImportError as e: - return {"error": f"synastry模块导入失败: {e}"} - # 构建两人数据 - p1 = {'moon_lon': args.moon1, 'gender': args.gender1 or 'M'} - p2 = {'moon_lon': args.moon2, 'gender': args.gender2 or 'F'} - if args.mars1 is not None: p1['mars_lon'] = args.mars1 - if args.mars2 is not None: p2['mars_lon'] = args.mars2 - if args.asc1 is not None: p1['asc_lon'] = args.asc1 - if args.asc2 is not None: p2['asc_lon'] = args.asc2 - return _build_response_envelope('synastry', calc_synastry(p1, p2), args=args, execution_status='executed') + from marriage_match_cli import run_marriage_match_cli + except ImportError as exc: + return {"error": f"marriage_match模块导入失败: {exc}"} + try: + result = run_marriage_match_cli(args) + except (OSError, ValueError, json.JSONDecodeError) as exc: + return {"error": str(exc)} + return _build_response_envelope('marriage-match', result, args=args, execution_status='executed') # ============================================================================ @@ -18217,16 +18214,12 @@ def main(): p.add_argument('--hour-from-sunrise', type=float, default=6.0, help='从日出起算的小时数(默认6h约正午)') - # 21. synastry (v3.7新增) - p = sub.add_parser('synastry', help='合盘分析(Ashta Koota 36分制)') - p.add_argument('--moon1', type=float, required=True, help='Person1月亮黄经') - p.add_argument('--moon2', type=float, required=True, help='Person2月亮黄经') - p.add_argument('--mars1', type=float, default=None, help='Person1火星黄经') - p.add_argument('--mars2', type=float, default=None, help='Person2火星黄经') - p.add_argument('--asc1', type=float, default=None, help='Person1上升黄经') - p.add_argument('--asc2', type=float, default=None, help='Person2上升黄经') - p.add_argument('--gender1', default='M', help='Person1性别') - p.add_argument('--gender2', default='F', help='Person2性别') + # 21. marriage-match(上游 bde5d615;只收盘快照) + p = sub.add_parser('marriage-match', help='婚恋合盘(两份盘快照)') + p.add_argument('--person-a', required=True, help='Person A 盘快照 JSON 文件') + p.add_argument('--person-b', required=True, help='Person B 盘快照 JSON 文件') + p.add_argument('--profile', default='practitioner_consensus_v1', + choices=['practitioner_consensus_v1', 'raman_muhurtha_v1']) # 22. full-reading (v3.7.1新增) p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)') @@ -18371,7 +18364,7 @@ def main(): 'varga-full': cmd_varga_full, 'aspects': cmd_aspects, 'jaimini': cmd_jaimini, 'nakshatra-adv': cmd_nakshatra_adv, 'nakshatra-dasha': cmd_nakshatra_dasha, 'nakshatra-full': cmd_nakshatra_full, 'argala': cmd_argala, 'tajika': cmd_tajika, - 'synastry': cmd_synastry, 'solar-return': cmd_solar_return, + 'marriage-match': cmd_marriage_match, 'solar-return': cmd_solar_return, 'narayana-dasha': cmd_narayana_dasha, 'muhurta': cmd_muhurta, 'full-reading': cmd_full_reading, 'pl9-export': cmd_pl9_export, 'prashna': cmd_prashna, 'double-transit-pac': cmd_double_transit_pac, diff --git a/scripts/marriage_match.py b/scripts/marriage_match.py new file mode 100644 index 00000000..8ed98f6f --- /dev/null +++ b/scripts/marriage_match.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""User-facing romantic/marriage compatibility contract. + +This module deliberately does not reuse the historical ``calc_kuja_dosha`` +score. That function combines Mars with five pressure planets and contains a +male/female asymmetric comparison. It remains available for legacy reports, +but is not a valid default for a new relationship product. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Dict, Iterable, Mapping, Optional + +from ashtakoot import ( + FRIENDSHIP, + NADI, + SIGN_LORDS, + SIGNS, + calculate_ashtakoot, + get_nakshatra, +) + + +PRODUCT_VERSION = "marriage_match_product_v1" +DEFAULT_PROFILE = "practitioner_consensus_v1" +VALID_RELATIONSHIP_TYPES = {"romantic_or_marriage", "romantic", "marriage"} +KUJA_PLANET = "Mars" +PAPASAMYA_PLANETS = ("Mars", "Saturn", "Sun", "Rahu", "Ketu") + +# These profiles are deliberately qualitative. The consensus did not support +# an invented severity percentage or a sex-specific tolerance threshold. +RULE_PROFILES = { + "practitioner_consensus_v1": { + "label": "Practitioner consensus v1", + "kuja_houses": (1, 2, 4, 7, 8, 12), + "reference_frames": ("lagna", "moon", "venus"), + "exact_house_sign_exceptions": { + 2: {"Gemini", "Virgo"}, + 4: {"Aries", "Scorpio"}, + 7: {"Capricorn", "Cancer"}, + 8: {"Sagittarius", "Pisces"}, + 12: {"Taurus", "Libra"}, + }, + "leo_aquarius_exception": "variant_only", + "own_or_exalted": "mitigates", + "jupiter_contact": "mitigates", + "moon_contact": "mitigates", + "papasamya_planets": PAPASAMYA_PLANETS, + "gender_asymmetry": False, + "notes": [ + "Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.", + "Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.", + "Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.", + "Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion.", + ], + }, + "raman_muhurtha_v1": { + "label": "B. V. Raman Muhurtha variant", + "kuja_houses": (2, 4, 7, 8, 12), + "reference_frames": ("lagna", "moon", "venus"), + "exact_house_sign_exceptions": { + 2: {"Gemini", "Virgo"}, + 4: {"Aries", "Scorpio"}, + 7: {"Capricorn", "Cancer"}, + 8: {"Sagittarius", "Pisces"}, + 12: {"Taurus", "Libra"}, + }, + "leo_aquarius_exception": "cancels", + "own_or_exalted": "mitigates", + "jupiter_contact": "mitigates", + "moon_contact": "mitigates", + "papasamya_planets": PAPASAMYA_PLANETS, + "gender_asymmetry": False, + "notes": [ + "Raman-style house set omits the first house. It is a selectable lineage, not the product default.", + "The traditional Leo/Aquarius exception is applied only in this variant profile.", + ], + }, +} + + +def available_profiles() -> Dict[str, Dict[str, Any]]: + """Return a presentation-safe copy of available rule profiles.""" + return deepcopy(RULE_PROFILES) + + +def _profile(profile_id: str) -> Dict[str, Any]: + if profile_id not in RULE_PROFILES: + raise ValueError("Unknown marriage-match rule profile: %s" % profile_id) + return RULE_PROFILES[profile_id] + + +def _lon(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) % 360.0 + if isinstance(value, Mapping): + for key in ("longitude", "degree_raw", "degree", "lon"): + if isinstance(value.get(key), (int, float)): + return float(value[key]) % 360.0 + return None + + +def _sign_index(value: Any) -> Optional[int]: + if isinstance(value, str) and value in SIGNS: + return SIGNS.index(value) + longitude = _lon(value) + return int(longitude // 30) if longitude is not None else None + + +def _planet_data(chart: Mapping[str, Any], planet: str) -> Mapping[str, Any]: + planets = chart.get("planets", {}) if isinstance(chart, Mapping) else {} + value = planets.get(planet, {}) if isinstance(planets, Mapping) else {} + return value if isinstance(value, Mapping) else {} + + +def _planet_lon(chart: Mapping[str, Any], planet: str) -> Optional[float]: + value = _planet_data(chart, planet) + longitude = _lon(value) + return longitude if longitude is not None else _lon(chart.get(planet + "_lon")) + + +def _reference_lon(chart: Mapping[str, Any], reference: str) -> Optional[float]: + if reference == "lagna": + for value in (chart.get("ascendant"), chart.get("lagna"), chart.get("asc_lon")): + longitude = _lon(value) + if longitude is not None: + return longitude + return None + return _planet_lon(chart, "Moon" if reference == "moon" else "Venus") + + +def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]: + value = _reference_lon(chart, reference) + if value is not None: + return int(value // 30) + if reference == "lagna": + # Aries is index 0, which is a valid reference sign rather than a + # missing value. Do not use boolean fallback here. + ascendant_sign = _sign_index(chart.get("ascendant")) + return ascendant_sign if ascendant_sign is not None else _sign_index(chart.get("lagna")) + return _sign_index(_planet_data(chart, "Moon" if reference == "moon" else "Venus")) + + +def _planet_sign(chart: Mapping[str, Any], planet: str) -> Optional[int]: + lon = _planet_lon(chart, planet) + return int(lon // 30) if lon is not None else _sign_index(_planet_data(chart, planet)) + + +def _house_from(reference_sign: Optional[int], planet_sign: Optional[int]) -> Optional[int]: + if reference_sign is None or planet_sign is None: + return None + return ((planet_sign - reference_sign) % 12) + 1 + + +def _same_sign_contact(chart: Mapping[str, Any], planet_a: str, planet_b: str) -> bool: + a, b = _planet_sign(chart, planet_a), _planet_sign(chart, planet_b) + return a is not None and a == b + + +def _mars_dignity(chart: Mapping[str, Any]) -> str: + sign_idx = _planet_sign(chart, "Mars") + if sign_idx is None: + return "unknown" + sign = SIGNS[sign_idx] + if sign == "Capricorn": + return "exalted" + if sign in {"Aries", "Scorpio"}: + return "own" + if sign == "Cancer": + return "debilitated" + return "other" + + +def _frame_kuja(chart: Mapping[str, Any], reference: str, profile: Mapping[str, Any]) -> Dict[str, Any]: + mars_sign_idx = _planet_sign(chart, "Mars") + ref_sign_idx = _reference_sign(chart, reference) + house = _house_from(ref_sign_idx, mars_sign_idx) + frame = { + "reference": reference, + "reference_sign": SIGNS[ref_sign_idx] if ref_sign_idx is not None else None, + "mars_sign": SIGNS[mars_sign_idx] if mars_sign_idx is not None else None, + "mars_house": house, + "raw_signal": house in set(profile["kuja_houses"]) if house is not None else None, + "status": "not_available" if house is None else "not_triggered", + "mitigations": [], + } + if house is None or not frame["raw_signal"]: + return frame + + mars_sign = frame["mars_sign"] + exact_exceptions = profile["exact_house_sign_exceptions"] + if mars_sign in exact_exceptions.get(house, set()): + frame["status"] = "cancelled_by_profile_exception" + frame["mitigations"].append("house_sign_exception") + return frame + if mars_sign in {"Leo", "Aquarius"}: + mode = profile["leo_aquarius_exception"] + if mode == "cancels": + frame["status"] = "cancelled_by_profile_exception" + frame["mitigations"].append("leo_aquarius_profile_exception") + return frame + if mode == "variant_only": + frame["mitigations"].append("leo_aquarius_exception_disputed") + + frame["status"] = "active" + dignity = _mars_dignity(chart) + if dignity in {"own", "exalted"}: + frame["mitigations"].append("mars_%s" % dignity) + if _same_sign_contact(chart, "Mars", "Jupiter"): + frame["mitigations"].append("jupiter_conjunction") + if _same_sign_contact(chart, "Mars", "Moon"): + frame["mitigations"].append("moon_conjunction") + return frame + + +def analyze_kuja(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]: + """Analyze Mars-only Kuja / Mangal Dosha across visible reference frames.""" + profile = _profile(profile_id) + if _planet_sign(chart, "Mars") is None: + return { + "system": "Kuja / Mangal Dosha", + "scope": "Mars only", + "profile_id": profile_id, + "status": "not_available", + "reason": "Mars position is required.", + "frames": [], + } + frames = [_frame_kuja(chart, ref, profile) for ref in profile["reference_frames"]] + active = [frame for frame in frames if frame["status"] == "active"] + cancelled = [frame for frame in frames if frame["status"] == "cancelled_by_profile_exception"] + severity = {0: "none", 1: "mild", 2: "material", 3: "strong"}[len(active)] + return { + "system": "Kuja / Mangal Dosha", + "scope": "Mars only", + "profile_id": profile_id, + "status": "executed", + "active_reference_count": len(active), + "cancelled_reference_count": len(cancelled), + "qualitative_level": severity, + "frames": frames, + "boundary": "Reference count is a presentation label, not a probability or a compatibility score.", + } + + +def analyze_papasamya(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]: + """Report five-planet pressure separately from Mars-only Kuja.""" + profile = _profile(profile_id) + rows = [] + for planet in profile["papasamya_planets"]: + planet_sign = _planet_sign(chart, planet) + if planet_sign is None: + rows.append({"planet": planet, "status": "not_available", "frames": []}) + continue + frames = [] + for reference in profile["reference_frames"]: + house = _house_from(_reference_sign(chart, reference), planet_sign) + frames.append({ + "reference": reference, + "house": house, + "in_pressure_house": house in set(profile["kuja_houses"]) if house is not None else None, + }) + rows.append({"planet": planet, "status": "executed", "frames": frames}) + active = [ + {"planet": row["planet"], "reference": frame["reference"], "house": frame["house"]} + for row in rows for frame in row.get("frames", []) if frame.get("in_pressure_house") + ] + return { + "system": "Papasamya pressure", + "scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.", + "profile_id": profile_id, + "status": "executed" if any(row["status"] == "executed" for row in rows) else "not_available", + "planet_rows": rows, + "active_placements": active, + "boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score.", + } + + +def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]: + """Compare two Kuja results without sex-specific tolerance or scoring.""" + a, b = analyze_kuja(person_a, profile_id), analyze_kuja(person_b, profile_id) + if a["status"] != "executed" or b["status"] != "executed": + outcome = "incomplete" + else: + difference = abs(a["active_reference_count"] - b["active_reference_count"]) + outcome = "not_triggered" if not a["active_reference_count"] and not b["active_reference_count"] else ( + "broadly_balanced" if difference <= 1 else "asymmetric" + ) + return { + "comparison_method": "neutral_reference_count", + "gender_asymmetry_used": False, + "outcome": outcome, + "person_a": a, + "person_b": b, + "boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation.", + } + + +def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]: + longitude = _planet_lon(chart, "Moon") + return longitude if longitude is not None else _lon(chart.get("moon_lon")) + + +def _are_natural_friends(lord_a: str, lord_b: str) -> bool: + if lord_a == lord_b: + return True + return lord_b in FRIENDSHIP.get(lord_a, {}).get("friend", []) and lord_a in FRIENDSHIP.get(lord_b, {}).get("friend", []) + + +def _ashtakoota_with_exceptions( + person_a: Mapping[str, Any], + person_b: Mapping[str, Any], +) -> Dict[str, Any]: + moon_a, moon_b = _moon_lon(person_a), _moon_lon(person_b) + if moon_a is None or moon_b is None: + return {"status": "not_available", "reason": "Both Moon longitudes are required for Ashtakoota."} + forward = calculate_ashtakoot(moon_a, moon_b) + reverse = calculate_ashtakoot(moon_b, moon_a) + # The historical Varna point encodes caste hierarchy. It cannot truthfully + # be relabelled as a modern value judgement, so the international product + # excludes it rather than deriving a replacement from lunar signs. + score_names = ("Vashya", "Tara", "Yoni", "GrahaMaitri", "Gana", "Bhakoot", "Nadi") + symmetric_scores = { + name: round( + (float(forward["scores"][name]) + float(reverse["scores"][name])) / 2.0, + 2, + ) + for name in score_names + } + international_core = { + "method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)", + "scores": symmetric_scores, + "total_score": round(sum(symmetric_scores.values()), 2), + "max_score": 35.0, + "match_percentage": round(sum(symmetric_scores.values()) / 35.0 * 100, 1), + "person_a_details": { + "moon_sign": SIGNS[int(moon_a // 30)], + "nakshatra": get_nakshatra(moon_a)["name"], + "nakshatra_pada": get_nakshatra(moon_a)["pada"], + }, + "person_b_details": { + "moon_sign": SIGNS[int(moon_b // 30)], + "nakshatra": get_nakshatra(moon_b)["name"], + "nakshatra_pada": get_nakshatra(moon_b)["pada"], + }, + "directionality": { + "status": "symmetric", + "rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a", + }, + "boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict.", + } + sign_a, sign_b = SIGNS[int(moon_a // 30)], SIGNS[int(moon_b // 30)] + nak_a, nak_b = get_nakshatra(moon_a), get_nakshatra(moon_b) + bhakoot_exceptions = [] + if symmetric_scores["Bhakoot"] == 0: + lord_a, lord_b = SIGN_LORDS[sign_a], SIGN_LORDS[sign_b] + if lord_a == lord_b: + bhakoot_exceptions.append("same_sign_lord") + elif _are_natural_friends(lord_a, lord_b): + bhakoot_exceptions.append("mutual_natural_friend_sign_lords") + nadi_exceptions = [] + if symmetric_scores["Nadi"] == 0: + if nak_a["index"] == nak_b["index"] and nak_a["pada"] != nak_b["pada"]: + nadi_exceptions.append("same_nakshatra_different_pada") + if sign_a == sign_b and nak_a["index"] != nak_b["index"]: + nadi_exceptions.append("same_rashi_different_nakshatra") + return { + "status": "executed", + "international_core": international_core, + "shared_life_dialogue": { + "status": "not_scored", + "storage": "no_answers_collected", + "prompts": [ + "What does a committed relationship mean to each of you?", + "How do you want to make decisions about money, home, and care responsibilities?", + "What boundaries, privacy expectations, and conflict-repair habits matter most?", + "How do you each imagine work, family, location, and personal autonomy over the next few years?", + ], + "boundary": "These are voluntary discussion prompts, not a compatibility test or a score.", + }, + "classical_varna": { + "status": "excluded_from_default", + "reason": "Historical caste hierarchy is not used in the international product or its default score.", + }, + "person_a": {"moon_sign": sign_a, "nakshatra": nak_a}, + "person_b": {"moon_sign": sign_b, "nakshatra": nak_b}, + "exception_review": { + "bhakoot": { + "raw_score": symmetric_scores["Bhakoot"], + "candidate_cancellations": bhakoot_exceptions, + "status": "candidate_mitigation" if bhakoot_exceptions else "none", + }, + "nadi": { + "raw_score": symmetric_scores["Nadi"], + "candidate_cancellations": nadi_exceptions, + "status": "candidate_mitigation" if nadi_exceptions else "none", + }, + "yoni": { + "raw_score": symmetric_scores["Yoni"], + "status": "legacy_three_band_table", + "boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score.", + }, + }, + } + + +def _structure_status(chart: Mapping[str, Any]) -> Dict[str, Any]: + keys = {"d1_relationship", "d9", "upapada", "darakaraka", "relationship_dasha"} + supplied = [key for key in keys if chart.get(key) is not None] + return { + "status": "partial" if supplied else "not_available", + "supplied_layers": sorted(supplied), + "required_for_complete_reading": ["D1 relationship structure", "D9", "UL", "DK", "relationship timing / Dasha"], + "boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review.", + } + + +def build_marriage_match_dossier( + person_a: Mapping[str, Any], + person_b: Mapping[str, Any], + *, + relationship_type: str = "romantic_or_marriage", + profile_id: str = DEFAULT_PROFILE, +) -> Dict[str, Any]: + """Build the stable, user-facing love/marriage compatibility payload. + + Inputs are chart snapshots only. The caller is responsible for calculating + D1/D9/timing snapshots; this function never stores birth details. + """ + if relationship_type not in VALID_RELATIONSHIP_TYPES: + raise ValueError("This product supports romantic/marriage matching only.") + profile = _profile(profile_id) + kuja = compare_kuja(person_a, person_b, profile_id) + ashtakoota = _ashtakoota_with_exceptions(person_a, person_b) + return { + "product": PRODUCT_VERSION, + "relationship_type": relationship_type, + "rule_profile": { + "id": profile_id, + "label": profile["label"], + "notes": profile["notes"], + "gender_asymmetry_used": False, + }, + "ashtakoota_36": ashtakoota, + "kuja_mangal_dosha": kuja, + "papasamya": { + "person_a": analyze_papasamya(person_a, profile_id), + "person_b": analyze_papasamya(person_b, profile_id), + }, + "relationship_structure": { + "person_a": _structure_status(person_a), + "person_b": _structure_status(person_b), + }, + "presentation": { + "headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.", + "decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.", + "not_in_scope": ["business partnerships", "family matching", "age-based cancellation", "sex-asymmetric tolerance"], + }, + "audit": { + "kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied", + "papasamya": "executed separately: five-planet pressure placement review", + "ashtakoota": ashtakoota["status"], + "d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated", + }, + } diff --git a/scripts/marriage_match_api.py b/scripts/marriage_match_api.py new file mode 100644 index 00000000..bc89387d --- /dev/null +++ b/scripts/marriage_match_api.py @@ -0,0 +1,106 @@ +"""HTTP-facing marriage match and full synastry dossier. + +The dossier endpoint caches on the two birth payloads plus ``GITHUB_SHA``, +the same scratch helper the report reference cache uses. A missing or +``unknown`` commit id skips the cache. +""" + +from __future__ import annotations + +import os +from typing import Any + +from marriage_match import build_marriage_match_dossier +from synastry_full_dossier import RULES_VERSION, build_synastry_full_dossier + +try: + from scripts import consult_full_data_cache as _cache +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + import consult_full_data_cache as _cache + + +CACHE_NAME = "synastry_full_dossier_cache" +_BIRTH_KEYS = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") + + +class MarriageMatchInputError(ValueError): + """The caller sent a shape this product does not accept.""" + + +def compute_marriage_match(payload: dict[str, Any]) -> dict[str, Any]: + """Snapshot-only product. Birth details are not accepted here.""" + if not isinstance(payload, dict): + raise MarriageMatchInputError("request body must be an object") + person_a = payload.get("person_a") + person_b = payload.get("person_b") + if not isinstance(person_a, dict) or not isinstance(person_b, dict): + raise MarriageMatchInputError("person_a and person_b must be chart snapshots") + profile_id = payload.get("profile_id") or "practitioner_consensus_v1" + if not isinstance(profile_id, str) or not profile_id.strip(): + raise MarriageMatchInputError("profile_id must be a string") + try: + return build_marriage_match_dossier( + person_a, + person_b, + profile_id=profile_id.strip(), + ) + except ValueError as exc: + raise MarriageMatchInputError(str(exc)) from exc + + +def _birth(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise MarriageMatchInputError(f"{label} must be an object") + missing = [key for key in _BIRTH_KEYS if value.get(key) is None] + if missing: + raise MarriageMatchInputError(f"{label} is missing {', '.join(missing)}") + return value + + +def _birth_identity(body: dict[str, Any]) -> dict[str, Any]: + node = body.get("node_mode", body.get("nodeMode", "mean")) + return { + "year": body.get("year"), + "month": body.get("month"), + "day": body.get("day"), + "hour": body.get("hour"), + "minute": body.get("minute"), + "second": body.get("second", 0), + "lat": body.get("lat"), + "lon": body.get("lon"), + "tz": body.get("tz"), + "ayanamsa": body.get("ayanamsa", "lahiri"), + "node_mode": node or "mean", + } + + +def _commit_sha() -> str: + return os.environ.get("GITHUB_SHA") or "" + + +def compute_synastry_full_dossier(handler: Any, body: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict): + raise MarriageMatchInputError("request body must be an object") + person_a = _birth(body.get("person_a"), "person_a") + person_b = _birth(body.get("person_b"), "person_b") + sha = _commit_sha() + + def build(_identity: dict[str, Any]) -> dict[str, Any]: + return build_synastry_full_dossier(handler, person_a, person_b) + + if sha in ("", "unknown"): + return build({}) + cached = _cache.get_or_build( + { + "person_a": _birth_identity(person_a), + "person_b": _birth_identity(person_b), + "github_sha": sha, + "rules_version": RULES_VERSION, + }, + build, + cache_name=CACHE_NAME, + ) + packet = cached["packet"] + if not isinstance(packet, dict): + raise MarriageMatchInputError("cached dossier was not an object") + return packet diff --git a/scripts/marriage_match_cli.py b/scripts/marriage_match_cli.py new file mode 100644 index 00000000..93a3aa64 --- /dev/null +++ b/scripts/marriage_match_cli.py @@ -0,0 +1,20 @@ +"""CLI for snapshot marriage matching. Birth details are not an input.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from marriage_match import build_marriage_match_dossier + + +def run_marriage_match_cli(args) -> dict: + person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8")) + person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8")) + if not isinstance(person_a, dict) or not isinstance(person_b, dict): + raise ValueError("person snapshots must be JSON objects") + return build_marriage_match_dossier( + person_a, + person_b, + profile_id=getattr(args, "profile", None) or "practitioner_consensus_v1", + ) diff --git a/scripts/synastry.py b/scripts/synastry.py deleted file mode 100644 index ec8ba5ea..00000000 --- a/scripts/synastry.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Synastry(合盘)模块 — 基于 dashaflow (MIT License) 核心算法 - -16因子兼容性分析系统: -- Ashtakoot 8因子(36分制) -- 附加8 Kuta(Mahendra/Stree Deergha/Vedha/Kuja Dosha/Rajju等) -""" - -from typing import Dict, List, Tuple, Any - -SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', - 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] - -SIGN_LORDS = { - 'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon', - 'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars', - 'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter' -} - -NAKSHATRAS = ['Ashwini', 'Bharani', 'Krittika', 'Rohini', 'Mrigashira', 'Ardra', - 'Punarvasu', 'Pushya', 'Ashlesha', 'Magha', 'Purva Phalguni', 'Uttara Phalguni', - 'Hasta', 'Chitra', 'Swati', 'Vishakha', 'Anuradha', 'Jyeshtha', - 'Mula', 'Purva Ashadha', 'Uttara Ashadha', 'Shravana', 'Dhanishta', 'Shatabhisha', - 'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati'] - -# ============================================================================= -# 数据表 (来自 dashaflow MIT License) -# ============================================================================= - -YONI_ANIMALS = [ - "Horse", "Elephant", "Sheep", "Serpent", "Serpent", "Dog", "Cat", "Sheep", "Cat", - "Rat", "Rat", "Cow", "Buffalo", "Tiger", "Buffalo", "Tiger", "Deer", "Deer", - "Dog", "Monkey", "Mongoose", "Monkey", "Lion", "Horse", "Lion", "Cow", "Elephant" -] - -YONI_ENEMIES = { - "Horse": "Buffalo", "Buffalo": "Horse", "Elephant": "Lion", "Lion": "Elephant", - "Sheep": "Monkey", "Monkey": "Sheep", "Serpent": "Mongoose", "Mongoose": "Serpent", - "Dog": "Deer", "Deer": "Dog", "Cat": "Rat", "Rat": "Cat", - "Cow": "Tiger", "Tiger": "Cow" -} - -GANA = [ - "Deva", "Manushya", "Rakshasa", "Manushya", "Deva", "Manushya", "Deva", "Deva", "Rakshasa", - "Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Deva", "Rakshasa", "Deva", "Rakshasa", - "Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Rakshasa", "Manushya", "Manushya", "Deva" -] - -NADI = [ - "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya", - "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", - "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya" -] - -VARNA = { - "Cancer": 1, "Scorpio": 1, "Pisces": 1, - "Aries": 2, "Leo": 2, "Sagittarius": 2, - "Taurus": 3, "Virgo": 3, "Capricorn": 3, - "Gemini": 4, "Libra": 4, "Aquarius": 4 -} - -VASHYA_TYPE = { - "Aries": "Chatushpada", "Taurus": "Chatushpada", - "Leo": "Vanachara", "Sagittarius": "Chatushpada", "Capricorn": "Chatushpada", - "Gemini": "Manava", "Virgo": "Manava", "Libra": "Manava", "Aquarius": "Manava", - "Cancer": "Jalachara", "Pisces": "Jalachara", - "Scorpio": "Keet", -} - -VEDHA_PAIRS = [(0,17),(1,16),(2,15),(3,14),(5,21),(6,20),(7,19),(8,18),(9,26),(10,25),(11,24),(12,23),(4,22)] - -RAJJU_GROUPS = { - "Pada":{0,8,9,17,18,26}, "Kati":{1,7,10,16,25,19}, - "Udara":{2,6,11,15,20,24}, "Kanta":{3,5,12,14,21,23}, "Sira":{4,13,22} -} -RAJJU_EFFECTS = {"Sira":"head—risk to husband","Kanta":"neck—risk to wife","Udara":"stomach—risk to children","Kati":"waist—poverty","Pada":"foot—wandering"} - -NATURAL_FRIENDS = { - 'Sun': ['Moon','Mars','Jupiter'], 'Moon': ['Sun','Mercury'], - 'Mars': ['Sun','Moon','Jupiter'], 'Mercury': ['Sun','Venus'], - 'Jupiter': ['Sun','Moon','Mars'], 'Venus': ['Mercury','Saturn'], - 'Saturn': ['Mercury','Venus'] -} -NATURAL_ENEMIES = { - 'Sun': ['Saturn','Venus'], 'Moon': [], 'Mars': ['Mercury'], - 'Mercury': ['Moon'], 'Jupiter': ['Mercury','Venus'], - 'Venus': ['Sun','Moon'], 'Saturn': ['Sun','Moon','Mars'] -} - -EXALTATION = {'Sun':('Aries',10),'Moon':('Taurus',3),'Mars':('Capricorn',28),'Mercury':('Virgo',15),'Jupiter':('Cancer',5),'Venus':('Pisces',27),'Saturn':('Libra',20)} -DEBILITATION = {p:(SIGNS[(SIGNS.index(s)+6)%12],d) for p,(s,d) in EXALTATION.items()} -OWN_SIGNS = {'Sun':{'Leo'},'Moon':{'Cancer'},'Mars':{'Aries','Scorpio'},'Mercury':{'Gemini','Virgo'},'Jupiter':{'Sagittarius','Pisces'},'Venus':{'Taurus','Libra'},'Saturn':{'Capricorn','Aquarius'}} - -_DOSHA_HOUSES = {2,4,7,8,12} -_HIGH_SEVERITY_HOUSES = {7,8} -_MARS_EXCEPTIONS = {2:{"Gemini","Virgo"},12:{"Taurus","Libra"},4:{"Aries","Scorpio"},7:{"Capricorn","Cancer"},8:{"Sagittarius","Pisces"}} -_MARS_EXEMPT_SIGNS = {"Aquarius","Leo"} - -# ============================================================================= -# Ashtakoot 8因子 (36分制) -# ============================================================================= - -def _calc_varna(m_sign, f_sign): - return 1.0 if VARNA[m_sign] <= VARNA[f_sign] else 0.0 - -def _calc_tara(m_nak, f_nak): - pts = 0.0 - if (f_nak-m_nak)%9 not in (2,4,6): pts += 1.5 - if (m_nak-f_nak)%9 not in (2,4,6): pts += 1.5 - return pts - -def _calc_yoni(m_nak, f_nak): - my, fy = YONI_ANIMALS[m_nak], YONI_ANIMALS[f_nak] - if my == fy: return 4.0 - if YONI_ENEMIES.get(my) == fy: return 0.0 - return 2.0 - -def _calc_graha_maitri(m_sign, f_sign): - ml, fl = SIGN_LORDS[m_sign], SIGN_LORDS[f_sign] - def _check(p1, p2): - if p1==p2: return 1.0 - if p2 in NATURAL_FRIENDS.get(p1,[]): return 1.0 - if p2 in NATURAL_ENEMIES.get(p1,[]): return 0.0 - return 0.5 - total = _check(ml,fl) + _check(fl,ml) - if total == 2.0: return 5.0 - if total == 1.5: return 4.0 - if total == 1.0: return 3.0 - if total == 0.5: return 1.0 - return 0.0 - -def _calc_gana(m_nak, f_nak): - mg, fg = GANA[m_nak], GANA[f_nak] - if mg == fg: return 6.0 - if mg=="Deva" and fg=="Manushya": return 6.0 - if mg=="Manushya" and fg=="Deva": return 5.0 - if "Rakshasa" in (mg,fg) and "Manushya" in (mg,fg): return 0.0 - if mg=="Rakshasa" and fg=="Deva": return 1.0 - if fg=="Rakshasa" and mg=="Deva": return 0.0 - return 0.0 - -def _calc_bhakoot(m_sign, f_sign): - mi, fi = SIGNS.index(m_sign), SIGNS.index(f_sign) - diff = (fi - mi) % 12 + 1 - return 7.0 if diff in (1,7,3,11,4,10) else 0.0 - -def _calc_nadi(m_nak, f_nak): - return 8.0 if NADI[m_nak] != NADI[f_nak] else 0.0 - -def _calc_vedha(m_nak, f_nak): - for a,b in VEDHA_PAIRS: - if (m_nak==a and f_nak==b) or (m_nak==b and f_nak==a): return "bad" - return "good" - -def _calc_rajju(m_nak, f_nak): - for group, indices in RAJJU_GROUPS.items(): - if m_nak in indices and f_nak in indices: - return {"result":"bad","group":group,"effect":RAJJU_EFFECTS[group]} - return {"result":"good","group":None,"effect":""} - -def _calc_mahendra(m_nak, f_nak): - cnt = ((m_nak-f_nak)%27)+1 - return "good" if cnt in (4,7,10,13,16,19,22,25) else "bad" - -def _calc_stree_deergha(m_nak, f_nak): - cnt = ((m_nak-f_nak)%27)+1 - return "good" if cnt >= 9 else "bad" - -# ============================================================================= -# 主计算函数 -# ============================================================================= - -def calc_ashtakoot(male_moon_degree: float, female_moon_degree: float, - male_chart: Dict = None, female_chart: Dict = None) -> Dict: - """ - 计算16因子Ashtakoot兼容性匹配(基于dashaflow MIT算法)。 - - Args: - male_moon_degree: 男性月亮黄道经度(0-360) - female_moon_degree: 女性月亮黄道经度(0-360) - male_chart: 男性星盘数据(可选,用于附加Kuta) - female_chart: 女性星盘数据(可选) - - Returns: - 完整的兼容性分析 - """ - m_nak = int(male_moon_degree / (360.0/27.0)) - f_nak = int(female_moon_degree / (360.0/27.0)) - m_sign = SIGNS[int(male_moon_degree/30)%12] - f_sign = SIGNS[int(female_moon_degree/30)%12] - - scores = { - "Varna": _calc_varna(m_sign, f_sign), - "Vashya": 2.0, # Simplified - "Tara": _calc_tara(m_nak, f_nak), - "Yoni": _calc_yoni(m_nak, f_nak), - "GrahaMaitri": _calc_graha_maitri(m_sign, f_sign), - "Gana": _calc_gana(m_nak, f_nak), - "Bhakoot": _calc_bhakoot(m_sign, f_sign), - "Nadi": _calc_nadi(m_nak, f_nak), - } - total = sum(scores.values()) - - rajju = _calc_rajju(m_nak, f_nak) - additional = { - "Mahendra": _calc_mahendra(m_nak, f_nak), - "StreeDeergha": _calc_stree_deergha(m_nak, f_nak), - "Vedha": _calc_vedha(m_nak, f_nak), - "Rajju": rajju, - } - - # Dosha缓解逻辑 - exceptions = [] - if scores["Nadi"]==0 and scores["Bhakoot"]>0 and rajju["result"]=="good": - exceptions.append("Nadi Dosha mitigated by good Bhakoot and Rajju") - if rajju["result"]=="bad" and scores["GrahaMaitri"]>=4 and scores["Bhakoot"]>0 and scores["Tara"]>=1.5 and additional["Mahendra"]=="good": - exceptions.append("Rajju Dosha mitigated") - - assessment = "优秀" if total>=28 else "良好" if total>=21 else "一般" if total>=18 else "不推荐" - approved = total >= 18.0 and (scores["Nadi"]>0 or len(exceptions)>0) - - return { - "version": "3.8-dashaflow-mit-adapted", - "method": "Ashtakoot 16因子兼容性分析 (dashaflow MIT)", - "male": {"moon_sign":m_sign,"nakshatra":NAKSHATRAS[m_nak],"gana":GANA[m_nak],"nadi":NADI[m_nak],"yoni":YONI_ANIMALS[m_nak]}, - "female": {"moon_sign":f_sign,"nakshatra":NAKSHATRAS[f_nak],"gana":GANA[f_nak],"nadi":NADI[f_nak],"yoni":YONI_ANIMALS[f_nak]}, - "scores": scores, - "total_score": total, - "max_score": 36.0, - "assessment": assessment, - "is_approved": approved, - "additional_kutas": additional, - "exceptions": exceptions, - } - - -def calc_synastry(male_chart: Dict, female_chart: Dict) -> Dict: - """ - Backward-compatible synastry wrapper used by integration tests and older CLI paths. - - Expected chart keys: - - moon_lon: Moon longitude in degrees (required) - - mars_lon / asc_lon / gender: optional, reserved for Kuja and extended factors - """ - if "moon_lon" not in male_chart or "moon_lon" not in female_chart: - raise ValueError("calc_synastry requires moon_lon in both male_chart and female_chart") - - result = calc_ashtakoot( - float(male_chart["moon_lon"]), - float(female_chart["moon_lon"]), - male_chart=male_chart, - female_chart=female_chart, - ) - - # Historical test/API compatibility: expose BadConstellations as an additional Kuta. - # A bad constellation condition is present if Vedha or Rajju is adverse. - vedha_bad = result["additional_kutas"].get("Vedha") == "bad" - rajju_bad = result["additional_kutas"].get("Rajju", {}).get("result") == "bad" - result["additional_kutas"]["BadConstellations"] = "bad" if (vedha_bad or rajju_bad) else "good" - return result diff --git a/scripts/synastry_full_dossier.py b/scripts/synastry_full_dossier.py new file mode 100644 index 00000000..7b1e8e64 --- /dev/null +++ b/scripts/synastry_full_dossier.py @@ -0,0 +1,70 @@ +"""Glue around the upstream full-synastry skill. + +Two birth payloads become two native full-readings, then the untouched +``build_full_synastry_dossier``. Raman is applied to the snapshots the skill +already extracted. This module does not edit those upstream files. +""" + +from __future__ import annotations + +import importlib.util +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, Callable + +from marriage_match import build_marriage_match_dossier + + +ROOT = Path(__file__).resolve().parents[1] +DOSSIER_SCRIPT = ( + ROOT + / "skills" + / "jyotish-full-synastry-reading" + / "scripts" + / "build_full_synastry_dossier.py" +) +RULES_VERSION = "bde5d615:marriage_match_product_v1" +RAMAN_PROFILE = "raman_muhurtha_v1" + +ReadingFn = Callable[[dict[str, Any]], dict[str, Any]] + + +def _dossier_builder(): + spec = importlib.util.spec_from_file_location( + "build_full_synastry_dossier", + DOSSIER_SCRIPT, + ) + if spec is None or spec.loader is None: + raise RuntimeError("full synastry dossier script is missing") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.build_full_synastry_dossier + + +def build_synastry_full_dossier( + handler: Any, + person_a: dict[str, Any], + person_b: dict[str, Any], + *, + read_chart: ReadingFn | None = None, +) -> dict[str, Any]: + """Run two full-readings in parallel, then both marriage-match profiles.""" + read = read_chart or handler._compute_full_reading_for_thematic + with ThreadPoolExecutor(max_workers=2) as pool: + future_a = pool.submit(read, person_a) + future_b = pool.submit(read, person_b) + reading_a = future_a.result() + reading_b = future_b.result() + dossier = _dossier_builder()(reading_a, reading_b) + snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {} + snapshots = snapshots if isinstance(snapshots, dict) else {} + raman = build_marriage_match_dossier( + snapshots.get("person_a") or {}, + snapshots.get("person_b") or {}, + profile_id=RAMAN_PROFILE, + ) + return { + "rules_version": RULES_VERSION, + "dossier": dossier, + "raman_marriage_match": raman, + } diff --git a/scripts/technique_registry.py b/scripts/technique_registry.py index b6896788..a22a7dc7 100644 --- a/scripts/technique_registry.py +++ b/scripts/technique_registry.py @@ -365,7 +365,7 @@ def _register_existing_techniques(): module_path="transit.py", compute_func="computeTransit"), TechniqueSpec("synastry", "合盘系统", TechniqueCategory.SYNASTRY, TechniqueLevel.L2_STANDARD, TechniqueStatus.STABLE, - module_path="synastry.py", compute_func="calc_synastry"), + module_path="marriage_match.py", compute_func="build_marriage_match_dossier"), TechniqueSpec("prashna", "问事系统", TechniqueCategory.PRASHNA, TechniqueLevel.L2_STANDARD, TechniqueStatus.WIP, module_path="prashna_context.py", compute_func="build_prashna_context"), diff --git a/skills/jyotish-full-synastry-reading/SKILL.md b/skills/jyotish-full-synastry-reading/SKILL.md new file mode 100644 index 00000000..5423ac36 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/SKILL.md @@ -0,0 +1,284 @@ +--- +name: jyotish-full-synastry-reading +description: Produce a full Jyotish love and marriage synastry reading from two birth records or two mature personal Jyotish reports. Use when a user wants a professional relationship interpretation, interaction patterns, shared timing, and relationship-relevant life-period analysis rather than a score-only matching result. +--- + +# Full Jyotish Synastry Reading + +Use this skill to deliver a relationship reading, not a matchmaking scorecard. +Accept either two birth profiles or two mature personal Jyotish reports. A +birth profile contains local birth date, local birth time, and full birthplace +for each person. Do not require a relationship goal, sex/gender label, +questionnaire, caste, or a choice between relationship categories. Infer the +reading emphasis from the two charts. + +## Input Contract + +1. Resolve each birthplace to a sourced latitude, longitude, and timezone. + Display the normalized places and coordinate precision before calculation. + Ask a follow-up only for an ambiguous place, missing local time, or a time + range. Never invent a hospital/street coordinate. +2. Use one visible calculation profile for both people. If neither person + specifies one, use `lahiri` with `mean` nodes and state that default. Do not + silently substitute Raman or mix ayanamsas, node modes, timezones, or house + systems between the two charts. +3. Keep birth data in the active session or user-approved private runtime + storage. Never place it in a repository file, test, Skill, prompt example, + Git commit, or public report metadata. +4. If either time is not sufficiently precise for D9, UL, DK, houses, or timing, + calculate the stable Moon-based layer but label all time-sensitive layers + `blocked` or `parameter_sensitive`. Do not replace them with a score. + +## Full-Report Input + +When the user supplies two formal personal reports, treat them as rich evidence +packets, not as unverified prose to summarize. + +1. Identify each report's birth profile, calculation profile, report generation + date, current/transit reference date, and available raw modules. Prefer an + accompanying JSON packet, delivery manifest, or structured appendix over + extracting values from narrative sentences. +2. Verify that both reports use the same ayanamsa, node mode, zodiac and house + profile before comparing signs, houses, Vargas, strength values, or Dashas. + If profiles differ, retain two separate lanes and do not make a direct + placement comparison until the differing chart is recomputed. +3. Reuse stable natal material: D1, Vargas, house lords, D9, UL, DK, Yoga, + Shadbala, Ashtakavarga, Sahams, natal Dasha roots, and book citations. + Recalculate all date-sensitive material from the preserved birth inputs for + the current day: active Dasha, transits, annual charts, current-year Sahams, + and timing windows. If the report has no usable birth inputs, retain old + dynamic material as a dated historical snapshot rather than calling it now. +4. Build a source map before interpreting: `report field -> pair comparison -> + user-facing section -> assertion label`. Do not add a second prose-only + interpretation layer that loses the reports' structured evidence. + +## Execution Order + +Run this sequence for every complete reading. Reuse repository producers; do +not hand-calculate degrees, vargas, dashas, or house overlays in prose. + +1. Run the repository pre-work gate and capability route: + + ```bash + python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 + python3 scripts/jyotish_engine.py audit-capabilities --mode table --route relationship_timing_strict + ``` + +2. With birth profiles, run `full-reading` separately for person A and person + B using the same `--ayanamsa lahiri --node-mode mean`, their normalized + coordinates, and the current date for `--today`/`--transit-date` unless the + user explicitly names a historical date. With report packets, perform the + Full-Report Input checks first, then recompute only date-sensitive layers + from the verified birth inputs. Preserve both raw result packets. + + For the normal birth-record workflow, use the controlled private runner + rather than manually assembling two engine commands. It runs both readings + concurrently, blocks mixed calculation profiles, and writes only outside + the repository: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py \ + --person-a /private/runtime/person-a-birth.json \ + --person-b /private/runtime/person-b-birth.json \ + --output-dir /private/runtime/synastry-run + ``` + + Each profile contains `date`, `time`, full `place`, `latitude`, `longitude`, + `timezone`, `ayanamsa`, and `node_mode`. Omit `--today` for the actual + current date; use it only for an explicitly requested historical reading. + The runner performs the repository preflight before consuming either profile + and emits `preflight.json`, `person_a.json`, `person_b.json`, + `execution_receipt.json`, and `dossier.json`, all private. + +3. After both packets are complete, run the execution receipt. It is a gate, + not a cosmetic appendix: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py \ + --person-a /private/runtime/person-a-full-reading.json \ + --person-b /private/runtime/person-b-full-reading.json + ``` + + Do not write birth records, packets, or the resulting receipt into Git. + The receipt must show the calculation-profile gate, every individual + technique row, and the status plus source inputs for every pair task before + writing conclusions. `ready_for_comparison` means only that both source + packets contain usable inputs; it is not an interpretation or a positive + relationship finding. Read + `references/full-technique-execution-matrix.md` in full for the required + pair comparisons and event-class-specific timing rules. + +4. Assemble the private two-person evidence dossier before narrative writing: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py \ + --person-a /private/runtime/person-a-full-reading.json \ + --person-b /private/runtime/person-b-full-reading.json \ + --output /private/runtime/full-synastry-dossier.json + ``` + + It inventories every source module from both packets and produces a private + `module_narrative_plan` showing the reader question or non-assertive + disposition for each; derives directional pair overlays, angular + contacts and Jaimini rashi contacts; builds a separate reciprocal-care + brief for what helps each recipient feel cherished, safe or pressured; and + provides a `reader_section_brief` that binds every customer section to its + relevant source modules, pair lens and separate D1/D9 recipient evidence; + then attaches the neutral + `marriage-match` dossier. The contacts are raw facts, not a compatibility + verdict. A report may only claim a full pair reading when its + `delivery_readiness` is `ready_for_narrative`; otherwise retain the raw data + and state exactly which primary comparison is blocked. + +5. Build the pair layer from those two computed charts. Call the new + `marriage-match` product for its narrow structural evidence only. It must + receive the two calculated snapshots, never raw birth data. Treat its + 35-point Ashtakoota core, Kuja, and Papasamya as separate evidence rows. + +6. Execute and compare all relationship-relevant layers available from the two + full readings: + + | Layer | Required comparison | + |---|---| + | D1 and functional roles | 1st/7th axes, 5th/7th/8th/12th lords, Venus, Jupiter, Moon, mutual house overlays, dignity, aspects, yogas, Shadbala and Ashtakavarga where relevant | + | D9 | each D9 Lagna and 7th axis, D9 7th lord, Venus/Jupiter, DK, vargottama, Pushkara, D1-D9 agreement and conflict | + | Jaimini | seven- and eight-karaka variants, DK, UL, UL second house, A7/Darapada, Arudha and Argala; retain rule-profile differences | + | Periods and transits | each person's Vimshottari plus Narayana; relation-related MD/AD/PD, Dasha sandhi, double-transit and LL/7L triggers; keep active periods separate. Calendar overlap is not couple resonance. A relationship-event conclusion requires an explicit event class, both charts' event-relevant promise and activation, pair interaction, transit/annual corroboration and supplied relationship status/history. | + | Annual and specialized layers | Tajika/Varshaphala, Vivah Saham, relevant Sahams, KP, Nakshatra, Bhrigu Pada, Chara and other route-required producers; show `executed`, `blocked`, or `not_applicable` exactly as returned | + | Matching evidence | international 35-point Ashtakoota core, exception review, Mars-only Kuja from Lagna/Moon/Venus, and five-planet Papasamya kept distinct | + + Evidence priority is fixed: each person's relationship promise first, the + two-chart relationship structure second, mutually activated timing third, + and matching/pressure indicators last. Kuja is a narrow conflict-pressure + indicator. It cannot lead the report, overturn a sound relationship + structure by itself, or become a pass/fail conclusion. + +7. Search the local book corpus with `scripts/search_jyotish_books.py` for + each material interpretive rule. Record book, page/line, and exact snippet. + For claims that rely on a web-only rule, complete MEVG research, record the + source tier and conflict, and add required intake records before treating it + as more than `reference_only`. + +8. Before writing Chinese prose, read + `references/chinese-synastry-writing-contract.md`. Preserve its protected + terms, degrees, dates, assertion labels and source paths. Apply the local + Chinese editing pass only after the evidence map is frozen; it may improve + sentence rhythm and remove boilerplate, but may not change an astrological + fact, a term mapping, a source boundary or a time window. + +9. Run global-web evidence and real-case calibration according to + `references/mandatory-verification-gate-protocol.md`. Absence of external + closure lowers assertion strength; it does not erase a runnable native + calculation or its raw data. + +## User-Facing Report + +Write a professional Chinese relationship report, not Western-style +relationship coaching. Read `references/customer-report-shape.md` before +writing. Follow its judgment order exactly: individual promise -> +spouse/marriage carrying capacity -> lived pair interaction -> relationship +progression -> shared gains and costs -> long-term durability -> event-specific +timing -> bounded auxiliary evidence. The internal writer must use the actual +Jyotish structures. Explain primary Jyotish terms when they make the judgment +clear, but do not turn the report into a formula table. Never expose the name, +formula, score or lineage of a supplementary cultural-star, Western or +question-chart technique; it may only add a convergent experiential detail. + +Open the Chinese and English reader reports with an evidence-backed experience +of affection, being valued or drawing closer, not an analyst-led contrast. +Where the reciprocal-care brief is executed, the **Attraction and Closeness** +section must write both directions: what helps A feel warmly connected, what +helps B feel warmly connected, what pressure makes either withdraw, and one +mutual practice tied to the actual pair evidence. This is reciprocal-care +guidance, never a promise of love or a tactic for extracting consent. + +The private dossier, not the customer report, must retain every row from the +execution receipt, the two-person comparison recorded for that row, whether it +informed a conclusion, and why it did not if it was not used. It retains raw +placements, degrees, period boundaries, source-book citations and +MEVG/case-calibration state. Never commit that dossier or append it to the +customer copy. + +Never use the total Ashtakoota value, a percentage, or a pass/fail label in the +headline. If a traditional matching value is helpful, describe it only in the +appendix as one limited Moon/nakshatra reference layer. + +When mature personal reports are available, add a short section before +relationship weather called **What each person is carrying into the +relationship**. Explain each person's relationship-relevant life season: +available capacity, career or relocation pressure, family/home obligations, +inner change, and current relationship activation. Only include non-relationship +domains when their chart evidence shows a material effect on relationship +availability or decision-making. Do not duplicate two full personal reports. + +Use one evidence label for every substantive conclusion: +`multi_system_consensus`, `single_system_inference`, +`parameter_sensitive`, `unclosed_divisional_chart`, +`user_history_verification_required`, or `blocked`. + +When bilingual delivery is requested, produce **two independently written +Markdown reports** from the same frozen dossier. Write Chinese under +`references/chinese-synastry-writing-contract.md`, then start a separate +English composition pass under +`references/english-synastry-writing-contract.md`. English must use the +structured dossier, original placements and original citations; never translate +the Chinese draft. Store the two independently written section payloads outside +the repository. Each payload declares `language`, +`composition: independent_from_structured_dossier`, an optional `title`, and +all nine stable section keys from the private `reader_section_brief`. Compile +and gate the delivery in one private step: + +```bash +python3 skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py \ + --dossier /private/runtime/full-synastry-dossier.json \ + --chinese-sections /private/runtime/chinese-sections.json \ + --english-sections /private/runtime/english-sections.json \ + --output-dir /private/runtime/synastry-delivery +``` + +Use `--dry-run` first when reviewing a draft. It validates the complete +delivery in memory and prints `would-deliver`; it does not create a directory +or any report artifact. + +The compiler writes Chinese Markdown, English Markdown and the private receipt +only after the gate passes. The receipt checks shared evidence coverage, +direct-English composition metadata, terminology, section-by-section narrative +depth, reciprocal-care coverage and report shape. It does not justify copying a +Chinese paragraph into English. + +## Boundaries + +- Do not output an approve/reject verdict, relationship ranking, percentage, + “soulmate” claim, or automatic marriage/divorce prediction. +- Do not call 35 points a 36-point score. Varna/caste is excluded from the + default product, and the 35-point core is only a small traditional evidence + layer. +- Do not merge Papasamya into Kuja, apply sex-asymmetric tolerance, or claim + that both people having Kuja automatically cancels it. +- Do not use a present relationship problem to retrofit chart calculations. +- Do not call a full-reading module "used" merely because its key exists. The + execution receipt must show an actual non-blocked result for both people, and + pair comparisons must be recorded separately from individual calculations. +- Do not hide a conflict between D1, D9, Jaimini, Dasha, or transit evidence. + Explain the disagreement and reduce the conclusion accordingly. +- Never make health, fertility, legal-marriage, financial, or safety claims + solely from a compatibility layer. + +## Runtime References + +- `references/strict-workflow-router.md`: relationship timing route and audit table. +- `references/mandatory-verification-gate-protocol.md`: web evidence and case calibration. +- `references/navamsa-marriage-deep-analysis.md`: D9 relationship microscope. +- `references/marriage-timing-comprehensive-techniques.md`: timing source map. +- `docs/marriage_match_product_contract.md`: narrow pair-evidence contract. +- `references/customer-report-shape.md`: user-readable report and full-report-input shape. +- `references/full-technique-execution-matrix.md`: mandatory all-technique and + pair-comparison execution matrix. +- `references/interaction-progression-evidence-map.md`: mandatory comparison + map for lived interaction, progression, shared gains and shared costs. +- `references/customer-topic-priorities.md`: reader-first ordering for the + customer report; presentation source only, never a Jyotish rule source. +- `references/chinese-synastry-writing-contract.md`: Chinese terminology, + evidence-preserving editing and final-language gate. +- `references/english-synastry-writing-contract.md`: direct English report + composition and terminology gate. diff --git a/skills/jyotish-full-synastry-reading/agents/openai.yaml b/skills/jyotish-full-synastry-reading/agents/openai.yaml new file mode 100644 index 00000000..e6f1e98b --- /dev/null +++ b/skills/jyotish-full-synastry-reading/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Full Jyotish Synastry Reading" + short_description: "Narrative love and marriage chart interpretation" + default_prompt: "Use $jyotish-full-synastry-reading for a full love/marriage synastry reading. Ask only for each person's local birth date, local birth time, and full birthplace when missing; resolve coordinates, calculate both charts under one visible profile, run the private full-synastry dossier, and write independently composed Chinese and English reports. Compile the two private section payloads through compose_full_synastry_delivery.py so every reader section passes the delivery gate. Lead with lived interaction, each person's distinct experience, progression, shared gains/pressures, and event-class-specific timing. Do not turn Ashtakoota, Kuja, or coincident Dasha dates into a score, verdict, or shared relationship event." diff --git a/skills/jyotish-full-synastry-reading/references/chinese-synastry-writing-contract.md b/skills/jyotish-full-synastry-reading/references/chinese-synastry-writing-contract.md new file mode 100644 index 00000000..14d2537c --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/chinese-synastry-writing-contract.md @@ -0,0 +1,122 @@ +# Chinese Synastry Writing Contract + +Use this after both full-reading packets, the pair receipt and the evidence map +are frozen. It is an editing contract, not a second astrology engine. + +## Canonical Terms + +Resolve terms before writing from these repository sources, in this order: + +1. `jyotish-app/glossary.js` for planet, sign, house, dignity and core Jyotish + names. +2. `references/nakshatra-chinese-quick-ref.md` for the 27 nakshatras and padas. +3. `references/classical-texts-translation-guide.md` for quotations and + classical-text translation boundaries. +4. `scripts/pl9_en_glossary.py` for established bilingual delivery terms. + +Keep the first use as `中文(Sanskrit / English, abbreviation)` when a term is +not already familiar to a general reader. Thereafter use one Chinese term +consistently. Keep `D1`, `D9`, `UL`, `A7`, `DK`, `MD/AD/PD`, `Kuja`, +`Papasamya`, `Vimshottari` and `Narayana` exactly as the evidence packet +identifies them. + +## Protected Facts + +Preserve these items in the private evidence dossier; do not paraphrase, round, +translate away, delete, or relocate them there without updating their source +map. The customer report uses only the values and boundaries necessary to +explain a conclusion, never internal source paths or engineering audit fields: + +- coordinates, dates, timezones, ayanamsa, node mode, zodiac and house profile; +- degrees, signs, houses, nakshatra/pada, Dasha boundaries and transit dates; +- assertion labels, execution statuses, source paths, book/page/line citations; +- rule-profile IDs and the distinction between Kuja and Papasamya; +- a result marked `blocked`, `parameter_sensitive`, + `unclosed_divisional_chart`, or `user_history_verification_required`. + +## Chinese Narrative Method + +Write one conclusion per paragraph in this sequence: + +`实盘结构 -> 印度占星判断 -> 两人相处中可观察的表现 -> 条件或反证`. + +Example form: “A 的七宫主 X 落在……,B 的……同时触及 A 的……。这使 +关系在……时更容易……;若……未同步出现,则此处只保留为……,不升级为婚姻判断。” + +Use direct Chinese. Name the actor, the planetary structure and the condition. +Do not replace a chart statement with generic coaching. Every practical +recommendation must point back to a named pair structure or timing window. + +For `结论总纲`, open with the reader's lived experience of affection, being +cherished, drawing closer or being understood, then explain what helps that +warmth become steady. If the calculated evidence does not support a settled +affection, describe an honest emerging pull rather than inventing warmth. Do +not open with an analyst-led contrast such as “这段关系的核心不是……而是……” or +“不是有没有吸引……”. The explanation may use contrast later, after the reader +has received the conclusion. + +For `吸引与亲密`, write both directions explicitly: what A can do that helps B +feel warmly connected, and what B can do that helps A feel warmly connected. +Also name the interaction pattern that cools or pressures each person and one +mutual practice that serves both. Derive each statement from recipient-side +pair evidence; never frame it as a technique for controlling affection, +obtaining consent or guaranteeing love. + +## Supplementary-Lane Boundary + +Star-relationship/Shukuyo, Western synastry/composite/Davison and Prashna are +supplementary lanes. They may supply a concrete experiential detail only when +they converge with the Indian-astrology primary chain. Do not reveal their +method, formula, label, score, lineage or rule name in the customer report. +If a supplementary lane conflicts with the primary chain, retain that conflict +in the private dossier and do not use the supplementary result to reverse the +primary conclusion. + +## Final Language Gate + +Apply the local `stop-slop-cn` editing rules only to prose after preserving +the protected facts. Reject or rewrite: + +- “灵魂伴侣、注定、宇宙安排、能量、百分百、一定会”; +- “多沟通、互相包容、顺其自然” when no chart-specific mechanism follows; +- generic opening filler, three-part slogans, duplicated conclusions, or a + paragraph that gives neither a placement nor a stated evidence boundary. + +Use short sentences for the judgment and longer sentences only for the chain +of evidence. The editor can remove redundancy and improve cadence; it cannot +convert a conditional inference into a fact. + +## Full-Report Depth + +The Chinese reader edition is normally 2,000–3,000 Chinese characters before +appendices. It must earn that length by explaining both people's distinct +experience, benefit, cost, pressure and usable condition in each relevant +section; never reach it by repeating chart placements, generic advice or raw +tables. The English edition is independently composed at comparable depth. +Each required reader section must contain a real paragraph, not merely a +heading or a sentence moved to another section. When the reciprocal-care brief +is executed, `吸引与亲密` must include both a warmth/connection condition and a +space/pressure condition for each side's interaction. The delivery gate checks +section depth separately from the overall character count. + +When the private dossier has executed D9 pair-interaction evidence, name D9 +(or “婚姻分盘”) naturally in the long-term/commitment discussion and state whether +it reinforces or qualifies the D1 interaction picture. Do not merge the two +into one undifferentiated conclusion, and do not omit D9 merely because D1 +already supplies an easier narrative. + +## Required Final Checks + +1. Every asserted relationship finding has an evidence label and at least one + source-map row. +2. Every timing paragraph states its event class: attraction/contact, + relationship formation, public formalisation, or legal marriage. +3. Shukuyo/star-relationship material, Western techniques and Prashna do not + appear by method name, score, formula, lineage or separate heading in the + customer report. Their convergent detail may be incorporated without source + disclosure; conflicts remain in the private dossier. +4. No individual birth data, raw chart packet or relationship history is + committed to Git. +5. Every required reader section passes the per-section depth gate; when + reciprocal-care evidence is executed, `吸引与亲密` passes its warmth-and-space + coverage gate in addition to the overall report-length gate. diff --git a/skills/jyotish-full-synastry-reading/references/customer-report-shape.md b/skills/jyotish-full-synastry-reading/references/customer-report-shape.md new file mode 100644 index 00000000..c9c3cc1e --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/customer-report-shape.md @@ -0,0 +1,147 @@ +# Professional Chinese Jyotish Synastry Report Shape + +This is the required presentation order for a full Chinese love/marriage +synastry report. It is not a prose template: every stated placement, Yoga, +period, date or inference must come from the two computed packets and carry an +assertion label. Never fill brackets with generic relationship language. + +## Internal Judgment Order + +Use the traditional order below. Do not begin with emotional coaching, a kuta +score, a planet glossary, or a claim that the two charts are "compatible". + +1. **命局承诺**: separately establish each person's D1 relationship promise + and functional roles, then D9 confirmation or contradiction. +2. **配偶与婚姻承载**: DK, UL/UL second, A7, marriage yogas, dignity, + Shadbala, Argala, dispositors, Vargottama/Pushkara and relevant blocks. +3. **两盘交感**: mutual overlays, drishti/conjunction, Moon/Venus/Mars axes, + Lagna and 5H/7H/8H/12H cross-links. State which chart supplies each fact. +4. **能否久处**: D9 agreement, UL/DK/A7, pressure/cancellation factors, + D30 and marriage-counting only as bounded context, never as a verdict. +5. **应期**: separately judge romantic activation, relationship formation, + public formalization and legal marriage through both persons' Vimshottari, + Narayana, Chara, Nakshatra layers, annual tools and transit corroboration. +6. **辅助证据与边界**: Kutas, Mars-only Kuja, separate Papasamya, KP, + optional Western/Shukuyo/Prashna. They support or qualify; they do not + overrule the promise-and-timing chain. + +## User-Facing Translation Boundary + +The customer report is a relationship reading, not a technical worksheet. The +Indian-astrology primary chain may be named and explained where it makes a +relationship conclusion clearer; do not turn it into a formula table. Scores, +rule profiles and degree tables stay in the private dossier. Supplementary +cultural-star, Western and question-chart methods must never be named or +explained in the customer copy: they can only enrich a detail when they agree +with the primary Indian-astrology conclusion. + +Translate each internal lane into a real shared-life question: + +| Internal evidence lane | Customer-facing question | +|---|---| +| relationship promise and spouse capacity | What does each person need in order to stay close and commit? | +| mutual contacts and relationship axes | What does being together feel like: attraction, safety, communication, conflict, closeness and space? | +| resource, home, work and pressure axes | What does the relationship make easier, and what does it cost either person in daily life? | +| separately activated timing evidence | For one named event class, are both people individually activated and is there corroborating pair, transit and annual evidence? | +| auxiliary systems | What detail is worth noticing after the core picture is already clear? | + +## Required User-Facing Sections + +```markdown +# [A] 与 [B] 的全量恋爱 / 婚姻合盘 + +## 一、结论总纲 + +先用 2–4 段回答: +- 双方本命是否各自具备可承载亲密关系/长期承诺的结构; +- 这段关系的主轴是吸引、互补、牵制、消耗还是共同成长; +- 最关键的一项支持与最关键的一项矛盾来自哪里; +- 当前只能判断到心动、成对、公开化还是法律婚姻,绝不混称“婚运”。 + +第一句必须从双方已经能感到的爱意、珍惜、靠近感或被理解的体验进入,并直接给出本盘结论。例如:“A 会被 B 的稳定回应安定下来,B 也会在 A 的认真投入里感到自己被珍惜。” 再温和说明这份爱意怎样才能走得更稳。只有证据确实不足时,才如实写成“仍在形成中的靠近感”,不得虚构热烈爱意。禁止用“这段关系的核心不是……而是……”“不是有没有吸引”等分析者自述开场;这类对比可以在后文解释,但不能抢占用户的第一句。 + +先在私有证据包内完成结构核对,再用自然语言回答。不得以“你们很有缘、需要沟通”替代具体的互动与条件。 + +## 二、各自带入关系的需求:能给出什么,也最怕什么 + +分别写 A 与 B:他们怎样获得安全感、怎样表达亲近、怎样面对承诺、在什么情境下会防御或撤退,以及能够提供什么。每一段都必须来自私有证据包的多层交叉,而非人格贴标签。 + +## 三、情绪安全与相处感受 + +按下列体验逐项写,不平均、不只写“互补”: + +1. **靠近时的感受**:第一印象、情绪安全、生活节奏。 +2. **吸引与亲密**:主动性、身体与情感靠近、需求强度。 +3. **沟通与争执**:误解如何发生,谁先推进、谁先沉默,怎样才能修复。 +4. **责任与现实**:共同价值、时间压力、家庭责任和能否把关系落到日常。 +5. **反复与距离**:非常规吸引、反复、疏离或执着;不得写成宿命论。 +6. **亲密边界**:恋爱、伴侣承诺、深度绑定、私人空间与消耗。 + +每项采用“互动场景 → A 的主观感受与代价 → B 的主观感受与代价 → 有利条件 → 失衡时的具体表现 → 可执行的关系安排”的六段法。不得因为两人周期日期相邻或重叠,就写成相同感受、对等付出或共同收益。 + +## 四、吸引与亲密:靠近、依赖与个人空间 + +说明双方的吸引如何升温、各自怎样表达喜欢和需要、亲密节奏是否容易同步,以及谁在关系靠近时更需要空间。不得借此对性行为、忠诚或人格作事实性判断。 + +本节必须分别回答四个用户真正会问的问题: + +1. A 在 B 做什么、怎样回应、怎样安排时间时,最容易感到被重视并愿意投入; +2. B 在 A 做什么、怎样回应、怎样安排时间时,最容易感到被重视并愿意投入; +3. 哪些具体互动会让其中一方感到被催促、被忽略或失去空间,从而降低亲密意愿; +4. 双方可以共同建立什么相处安排,让爱意持续升温而不是靠反复确认。 + +每项都必须回扣接收方的实际盘面证据,写成双向照顾和关系练习,不得写成让一方“拿捏”或诱导另一方的技巧。 + +## 五、沟通、冲突与修复 + +具体写清楚分歧通常从哪里开始、谁会先推进或回避、冷却后是否能重新接上,以及最有效的修复条件。禁止用“多沟通”替代对真实互动机制的解释。 + +## 六、关系如何推进:从靠近到承诺的门槛 + +分别说明什么会推动关系从心动走向确认、公开或长期共同生活;什么条件会让推进变慢或停住。时间必须写可用窗口与现实前提。主链的印度占星判断可自然说明,但不堆砌公式、分数或技术清单。 + +## 七、共同生活的收益与压力:家、钱、工作、家人和私人空间 + +必须分别回答:关系可能让双方在哪些方面更稳定、更有支持;又会让谁在工作、迁移、金钱安排、家庭责任、社交生活、休息或私人空间上付出更多。禁止把共同收益写成财富保证,也禁止把压力写成必然分手。 + +## 八、能不能稳定走下去:承诺、共同生活与修复 + +说明长期共同生活中最稳定的支点、最易积累的压力、修复能否有效,以及双方需要先达成什么现实安排。内部必须分别审阅不同压力来源;用户稿不展示它们的名称,也不得用“双方都有同类压力所以抵消”。 +当私有证据包已经计算出 D9 双人互动时,本节必须自然交代它对 D1 互动结论是强化、补充还是构成条件,不得把二者合并成单一判断。 + +## 九、关系应期:四类事件,四套门槛 + +每个时间窗都必须有起止日期/边界和双人证据。按下表分节,未满足门槛的项写 +`blocked` 或 `user_history_verification_required`,不以长篇解释填充。 + +| 事件 | 最低共同证据 | 禁止偷换 | +|---|---|---| +| 心动/接触 | 5H/5L、金星/月亮/罗喉、各自周期和行运 | 说成结婚 | +| 成为伴侣 | 7L、DK、UL、A7、双方可用周期 | 说成法律婚 | +| 公开化 | 社会轴、UL/A7、年度盘与现实条件 | 说成健康或必婚 | +| 法律婚姻 | D9、UL/UL2、Vivah Saham、双方 Dasha、行运/年度收敛 | 单一 Dasha、Punarphoo、分数 | + +内部必须在两人的多个时间层上交叉复核;用户稿只给出窗口、它对应的关系阶段、双方现实上需要同时具备的条件与不确定性。两人的大运或子运日期并行/重叠只可用于说明各自背景,不能单独构成任何关系事件窗口。 + +## 十、现实建议:只回答这段关系真正提出的问题 + +每条建议必须回扣前文一条具体配置,例如: +“由于 A 的[配置]与 B 的[配置]同时触发 4/10 轴,居住地与工作安排需在 +[窗口]前说清”,不能输出通用的“多沟通、互相包容”。 + +The source map, audit receipt, calculation parameters, raw structures, +citations and all uncertainty labels belong only to the **私有证据包 (private +evidence pack)**. They are required for review but never appended to a customer +report. +``` + +## Chinese Style Rules + +- 先给判断依据,再给翻译;避免“宇宙安排、灵魂伴侣、注定、能量”等泛词。 +- 使用“七宫主落……,同时……,故此关系中容易/不容易……”的递进句式, + 不把印度占星术语硬译成心理学标签。 +- 每一段最多建立一个主结论;结论必须含至少两条同向证据,或标 + `single_system_inference`。 +- “可能”必须交代条件;“会”必须对应实盘结构与有效时间窗。 +- 同时保留 A 和 B 的主体性:写“双方在……时容易出现……”,而不是给某一方定性。 +- 普通用户的正文可读;主链的印度占星判断可自然解释,原始落点、公式与审计细节留在私有证据包。补充层的方法不得出现在交付稿。可用时间窗口仍须明确,但不得伪装为确定事件。 diff --git a/skills/jyotish-full-synastry-reading/references/customer-topic-priorities.md b/skills/jyotish-full-synastry-reading/references/customer-topic-priorities.md new file mode 100644 index 00000000..31577784 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/customer-topic-priorities.md @@ -0,0 +1,42 @@ +# Customer Topic Priorities for a Love / Marriage Synastry Report + +Use this as a presentation priority, not as an astrology rule source. The +reader should meet lived questions before technical detail or a compatibility +score. + +## Required Order + +1. **What is happening between us now?** The central pull, mismatch and the + relationship stage that the report can actually address. +2. **How do we feel together?** Emotional safety, attraction and closeness, + communication, conflict, repair, pursuit and withdrawal. +3. **Can the relationship move forward?** Conditions for becoming a couple, + defining the relationship, public commitment, shared living or marriage. +4. **What do we gain and what does it cost?** Home, money, work, family, + friendship, personal space and uneven responsibility. +5. **Can it stay stable?** Commitment, trust, intimacy, boundaries and repair + under ordinary pressure. +6. **What is the next usable period?** A bounded time window and the real-life + conditions necessary for it; never a guarantee. + +## Product Research Notes + +Public synastry products consistently foreground emotional connection, +communication, attraction, conflict, shared values and long-term patterns +rather than a raw score. They also present a readable explanation rather than +a chart dump. The report should preserve that reading rhythm while using the +Indian-astrology evidence chain internally. + +- SynastryCalc, “Free Synastry Report”: emotional, love, communication, + chemistry and long-term patterns. +- AstroMaster, “Relationship Compatibility Report”: attraction, emotional + compatibility, communication, shared values, conflict and growth. + +- AstroAk, “Synastry”: first impressions, emotional bonds, communication, + recurring tension, growth and mutual planetary contact. + +- Astrogya, “Relationship Forecast”: emotional connection, attraction, tension + and growth over time; use neutral prompts rather than a verdict. + + +These are presentation references, not source authority for Jyotish rules. diff --git a/skills/jyotish-full-synastry-reading/references/english-synastry-writing-contract.md b/skills/jyotish-full-synastry-reading/references/english-synastry-writing-contract.md new file mode 100644 index 00000000..d556565e --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/english-synastry-writing-contract.md @@ -0,0 +1,87 @@ +# English Synastry Writing Contract + +Write the English report directly from the frozen two-person evidence dossier. +Do not translate the Chinese report, use Chinese sentence order, or treat a +Chinese prose draft as source material. The two languages share facts and +evidence identifiers, not wording. + +## Canonical Vocabulary + +Use the native terms already present in the calculated packet: `Lagna`, +`Navamsa (D9)`, `Upapada Lagna (UL)`, `Darakaraka (DK)`, `Darapada (A7)`, +`Vimshottari`, `Narayana Dasha`, `Kuja`, and `Papasamya`. On first use, give a +short plain-English gloss. Do not invent literal translations for Sanskrit +terms or recast them as modern psychological diagnoses. + +## English Report Order + +1. **Relationship Thesis**: state the central support, central strain and the + exact event class the timing section can address. +2. **What Each Person Brings**: establish D1 promise, functional roles and D9 + confirmation/contradiction for Person A and Person B separately. +3. **Emotional Safety and Lived Experience**: explain whether each person is + likely to feel seen, safe and at ease in ordinary contact. +4. **Attraction and Closeness**: explain how desire, affection, closeness and + the need for space develop without diagnosing sexuality or fidelity. +5. **Communication, Conflict and Repair**: explain how a disagreement starts, + who tends to pursue or withdraw, and what permits reconnection. +6. **How the Relationship Progresses**: show what turns attraction into a + partnership, what delays it, and which real-life conditions matter. +7. **Shared Life: Gains and Pressures**: cover home, money, work demands, family, social + life, private space and the uneven burdens either person may carry. +8. **Can It Stay Steady?**: address D9, DK, UL/UL2, A7, Argala, strength and + actual pressure/mitigation factors without an approval/rejection verdict. +9. **Relationship Weather**: keep attraction/contact, relationship formation, + public formalisation and legal marriage as separate event classes. State + usable date boundaries and the conditions around them. +10. **Practical Focus**: name the concrete shared-life issue revealed by the + chart. Do not append generic communication advice. + +Open **Relationship Thesis** with the reader's lived question and a direct +answer, not an analyst-led contrast such as “the core of this relationship is +not X but Y.” Explain the technical distinction after the opening conclusion. + +Begin with an evidence-supported experience of affection, being cherished, +growing closer or being understood, then explain what helps that warmth become +steady. If the evidence supports only an emerging pull, write that honestly; +do not manufacture romantic warmth. In **Attraction and Closeness**, write both +directions explicitly: what A can do that helps B feel warmly connected, what +B can do that helps A feel warmly connected, what cools or pressures each +person, and one mutual practice that serves both. Never frame this as a way to +control affection, obtain consent, or guarantee love. + +Meet the full-report length through substance in every required reader section, +not by placing almost all prose in one section. When reciprocal-care evidence +is executed, **Attraction and Closeness** must cover both a condition for warm +connection and a condition involving space, pressure or withdrawal. The +delivery gate checks these section-level requirements separately from the total +word count. +Supplementary cultural-star, Western and question-chart lanes can supply a +detail only when they converge with the Jyotish conclusion. Do not name those +supplementary methods, their formulas, their scores or their lineages in the +customer report. Their provenance and any conflict remain in the private +delivery dossier. + +## Sentence Discipline + +Write in direct professional English: “A’s seventh lord …; B’s … activates +that axis. In practice, this can make … more likely when … is also active.” +Then name the limiting condition. Avoid “soulmate”, “destined”, “the universe”, +“energy”, “guaranteed”, vague therapy language, and score-led conclusions. + +## Final Check + +- Compose English from the dossier and original book/web citations only. +- Retain every date, degree and assertion boundary used in a conclusion exactly. + Do not force a Sanskrit term into the report merely to demonstrate coverage: + use it only when it makes the specific conclusion clearer. +- Keep Chinese cultural-star, Western and Prashna material internally + segregated; only surface an unnamed detail when it independently converges + with the Jyotish conclusion. +- Never call a favourable kuta total, Kuja cancellation or one Dasha period a + marriage prediction. +- When the private dossier has executed D9 pair-interaction evidence, refer to + Navamsa/D9 naturally in the long-term or commitment discussion and say + whether it reinforces or qualifies the D1 interaction picture. Do not merge + the two into an undifferentiated conclusion or omit D9 because D1 is easier + to narrate. diff --git a/skills/jyotish-full-synastry-reading/references/full-technique-execution-matrix.md b/skills/jyotish-full-synastry-reading/references/full-technique-execution-matrix.md new file mode 100644 index 00000000..b38351dc --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/full-technique-execution-matrix.md @@ -0,0 +1,125 @@ +# Full Synastry Execution Matrix + +Use this reference for every full love/marriage report. It is an execution +matrix, not a menu: a relevant available row must be marked `executed`, +`parameter_sensitive`, or `blocked` in the final technical appendix. + +## 1. Individual Promise: Run for Both People + +| Evidence family | Required outputs | Report role | +|---|---|---| +| Natal D1 + functional roles + Bhava Chalit | 1/5/7/8/12 houses and lords, Venus, Jupiter, Moon, Mars, DK; natural and functional roles; whole-sign and Bhava differences | Relationship capacity, attraction and commitment structure | +| D9 + dignity + Vargottama + Pushkara + RTN | D9 Lagna, 7H/7L, Venus/Jupiter/DK, dignity reversals, D1-D9 agreement | Long-term relationship and shared-life structure | +| Strength and flow | Shadbala relative rank, Ashtakavarga, Vimsopaka where available, dispositor/final-dispositor chains | Do not elevate a weak indicator above a stronger conflicting one | +| Jaimini promise | 7/8-karaka DK, UL, UL second, A7/Darapada, AL, Arudha, Argala, Karakamsha/Swamsha when source-supported | Partner image, public formation, sustenance, support/blocking | +| Pattern and pressure | Marriage yogas, Mangal, Punarphoo, marriage-counting, D30 | Context only; Punarphoo and D30 never decide marriage | + +## 2. Pair Structure: Compare, Do Not Average People + +| Pair comparison | Minimum evidence | User-facing question | +|---|---|---| +| Mutual overlays | Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, nodes into 1/4/5/7/8/12 | How closeness, communication, desire, responsibility and withdrawal feel | +| Relationship axes | Separate D1 and D9 Lagna, Moon, Venus, Mars and 5H/7H/8H/12H cross-links | What is easy, what repeats under stress, and how repair works; never merge D1 lived interaction with D9 commitment structure | +| Long-term compatibility | D1-D9 agreement, DK/UL/A7, UL second, Argala and strengths | What needs an explicit agreement about home, time, money, family, autonomy or responsibility | +| Traditional matching | 35-point international core, additional Kutas, raw exceptions | Limited Moon/nakshatra reference only; never headline or verdict | +| Pressure layers | Mars-only Kuja from Lagna/Moon/Venus; Papasamya separate | Conflict pressure, never a pass/fail gate | + +### Evidence precedence + +1. Each person's relationship promise: D1/D9, functional roles, 7th-house + structure, Venus, Moon, DK, UL/A7 and relevant strength evidence. +2. The relationship structure between the two charts: reciprocal contacts, + Jaimini indicators, D9 agreement and practical long-term containers. +3. Event-class timing: each person's activated Dasha, transit and annual + evidence, compared as separate event-relevant contexts. +4. Narrow matching and pressure rows: Ashtakoota, Kuja and Papasamya. + +Kuja is not a general measure of compatibility. Its result only describes a +possible Mars-patterned pressure after the first three layers have been read; +it cannot negate a relationship or establish one alone. + +## 3. Timing: Keep Event Classes Separate + +| Event class | Required convergence | Never infer from alone | +|---|---|---| +| `romantic_activation` | 5H/5L, Venus/Moon/Rahu, individual periods and transits | A legal-marriage event | +| `relationship_formation` | 7L, DK, UL, A7, both people’s usable periods | A wedding or legal commitment | +| `public_formalization` | Social/public axes, UL/A7 and annual context | Proof that a relationship is healthy or legally married | +| `legal_marriage` | D9 + UL/UL second + Vivah Saham + both Dasha lanes + transit/annual corroboration | A single transit, Punarphoo, 5L period or score | + +### Pair-event admission rule + +The full-reading packet may contain a generic single-chart score or a broad +window verdict. It is **not** a relationship event label. The pair adapter must +ignore that score/verdict for event-resonance purposes unless the packet +supplies a named event class and separately records present `promise`, +`activation`, `manifestation` and `label` stages. Both people must meet the +same event-class gate. Even then the state is only +`candidate_alignment_requires_event_history`: pair interaction plus relevant +transit/annual evidence and the actual relationship state or dated event +history are required before saying an event occurred. `separation_or_reconciliation` +and `legal_divorce` remain blocked until a dedicated native event-class +producer supplies their evidence; they must not be inferred as the reverse of +formation or marriage. + +The existing `relationship_analysis.classify_relationship_event_class` may +contribute a named individual candidate for romantic activation, +relationship formation or legal marriage. Its own `observation_only` boundary +is preserved. Matching observation-only candidates produce only +`parallel_observation_only`, never a relationship-event claim and never a +replacement for the strict four-stage gate. + +Run Vimshottari, Dasha Sandhi, Narayana, Chara, relationship-relevant +Nakshatra Dasha, double-transit/LL7L observations, KP 7H, Tajika/solar return, +Muntha, Vivah/related Sahams, Bhrigu Pada (parameter-sensitive), Tithi Lord and +Sudarshana where the source packet exposes them. Use Western timing only as an +independent support lane and never to upgrade Jyotish timing precision. + +## 4. Optional but Separate Lanes + +- **Western synastry/composite/Davison**: use only when separately calculated + from compatible tropical inputs. Present as supporting perspective, not + Jyotish evidence. Do not claim a composite or Davison chart has run merely + because a natal Western packet exists. +- **Shukuyo / star relationship**: use 27 lunar mansions + 11-character cycle + + direction + distance. It is cultural narrative context, not Nakshatra + Koota and not an Indian event timer. +- **Prashna**: needs the real question time and place. It is guarded context, + never a replacement for two natal charts. + +## 5. Repository Evidence Router + +Run every available relevant producer, then retain its role in the pair packet. +Do not turn a listed source into a conclusion until it has actual output for +both people. + +The private dossier's `module_narrative_plan` must contain every computed +module for both people. Each row names the user-facing section it can support, +or a non-assertive disposition such as `bounded_context_only` or +`private_review_required_before_omission`. This prevents a full calculation +from silently collapsing to D1, D9 and a matching score while still keeping +engineering details out of the customer report. + +| Evidence lane | Local producer or source | Pair-report role | Boundary | +|---|---|---|---| +| Natal relationship frame | `full-reading.modules.chart`, `functional_benefic_malefic`, `bhava_chalit`, `relationship` | Each person's relationship promise before comparison | Never infer it from a kuta score | +| Varga and strength | `varga_full`, `vimsopaka`, `vargottama`, `pushkara`, `shadbala`, `ashtakavarga`, `dispositor_chains` | Weight D1/D9 agreement and conflicts | D30 and weak/parameter-sensitive layers stay contextual | +| Jaimini | `jaimini`, `special_lagnas`, `argala`, `rashi_tulya_navamsa` | DK, UL/UL2, A7, Arudha and obstruction/support | Preserve 7/8-karaka and rule-profile differences | +| Jyotish timing | `dasha`, `narayana_dasha`, Chara in `jaimini`, `dasha_sandhi`, `nakshatra_dasha`, `double_transit_pac`, `transit_ll7l`, `kp` | Compare two people's activation windows by event class | A period in one chart is not a couple event | +| Annual/event points | `tajika_yogas`, `solar_return`, `muntha`, `sahams`, `tithi_lord`, `bhrigu_pada_dasha`, `marriage_counting` | Corroborate, qualify, or expose timing disagreement | Bhrigu Pada remains parameter-sensitive; no fabricated precision | +| Traditional matching | `marriage-match` product / `scripts/marriage_match.py` | Order-neutral 35-point Moon/nakshatra reference, exception review, Mars-only Kuja and separate Papasamya | Not a pass/fail score or a replacement for natal promise | +| Star relationship / Shukuyo | `xhs-minitool/app.js`, `scripts/star_relationship_contract.py`, `scripts/star_relationship_coaching.py` | Optional Chinese cultural narrative about the felt relationship pattern | Separate from Jyotish; never call 安坏/危成/荣亲 an Indian rule or an event timer | +| Western pair layer | `unified_western_reading` plus separately calculated synastry/composite/Davison inputs | Independent supporting lens | Natal Western output alone is insufficient | +| Prashna | real question chart and `prashna` output | Context for a narrow present-tense question | Never replaces two natal charts or timing convergence | +| Source/case layer | `scripts/search_jyotish_books.py`, MEVG protocol, real-case calibration records | Grounds material rules and records conflicts | Cite book/page/line and source grade; never invent a case result | + +## 6. Mandatory Gates Before Narrative + +1. Compare ayanamsa, node mode, zodiac and house profile. Do not compare + signs/houses directly if they differ. +2. Run `validate_full_synastry_packets.py` after producing both full readings. +3. Build `source field -> pair comparison -> user section -> assertion label`. +4. Obtain book-corpus evidence for each material interpretive rule. +5. Complete MEVG and real-case calibration; record conflict and transferability. +6. Keep `executed`, `parameter_sensitive`, `blocked`, and `not_applicable` + separate. A module being present does not mean it can produce a conclusion. diff --git a/skills/jyotish-full-synastry-reading/references/interaction-progression-evidence-map.md b/skills/jyotish-full-synastry-reading/references/interaction-progression-evidence-map.md new file mode 100644 index 00000000..264056be --- /dev/null +++ b/skills/jyotish-full-synastry-reading/references/interaction-progression-evidence-map.md @@ -0,0 +1,70 @@ +# Full Synastry Interaction, Progression and Trade-off Map + +Use this map after both native full-reading packets have been calculated under +one compatible profile. It makes every runnable relationship-relevant producer +accountable without turning a single-chart result into a claim about the pair. + +## Evidence Lines + +| User question | Primary Jyotish evidence to compare | Supporting evidence | What may be concluded | Boundary | +|---|---|---|---|---| +| What does being together feel like? | Mutual Lagna/Moon/Venus/Mars/Mercury contacts, 5/7/8/12 axes, aspects, house map, D9 | planetary friendship, avasthas, D9-expanded, RTN | emotional safety, attraction rhythm, communication style, conflict and repair pattern | Never infer consent, sexual behaviour or abuse from a chart. | +| How does the relationship move forward? | both Vimshottari, Narayana, Chara/Jaimini, DK/UL/A7, Dasha Sandhi | Nakshatra Dasha, Yogini, Ashtottari, Kalachakra, Navamsha Dasha, Dasa convergence | conditional windows for contact, formation, public commitment and legal marriage | A period in one chart is not a couple event; every event class needs both charts and real-life conditions. | +| What can each gain together? | 2/4/11 and their lords, D2, D4, D9, D1-D9 dispositors, strength/Ashtakavarga | D10 and D11, Bhava Bala, Vimsopaka, Vargottama, Pushkara | mutual support around home, resources, networks, confidence and life direction | Never make income, property or family-resource guarantees. | +| What does the relationship cost or strain? | 6/8/12, Mars/Saturn/nodes, D30, yogas/doshas, Argala obstruction, avasthas | Shadbala/Bhava Bala, sensitive points, Sade Sati/current transit context | likely pressure channels: time, work, family duty, privacy, recovery, conflict escalation | Kuja and Papasamya remain separate; neither is a pass/fail gate. | +| Can it last in shared life? | D9, DK, UL and UL-second, A7, Argala, Jaimini, D1/D9 agreement | marriage counting, Vivah Saham, Sahams, tithi lord, RTN, disposition chains | commitment container, repair capacity, family/home negotiation and durability conditions | Marriage-counting/D30/Bhrigu layers cannot decide the result alone. | +| What is active now? | transits, double transit/PAC, LL7L, annual chart, Tajika, Muntha, Sahams, KP | solar return, Bhrigu Pada, Pancha Pakshi as time-local context | whether the present period supports contact, formation, formalisation or review | Keep event classes separate. Do not make month-level certainty from one indicator. | + +## Mandatory Comparison Discipline + +1. Record Person A and Person B separately before drawing a pair conclusion. +2. Record the contact direction: `A activates B`, `B activates A`, `mutual`, + or `parallel but not cross-chart`. The private dossier's + `pair_interaction_evidence` is the mandatory raw source for overlays, + angular contacts and Jaimini rashi contacts. Do not treat directions as + interchangeable. +3. For every interaction result, state the benefit, the cost and the condition + that decides which side becomes visible in lived experience. +4. A single technique can create `single_system_inference` only. A general + relationship conclusion needs convergent natal structure, pair interaction + and timing or must retain a lower assertion label. +5. Preserve disagreement. A supportive natal contact plus a non-overlapping + period is a potential, not evidence that a relationship will form now. +6. Parallel individual Dasha dates are not a couple window. They can describe + why each person is differently available, pressured, receptive or + withdrawn, but they do not establish shared feelings, equal gains/losses, + or any relationship event. For a conclusion about romantic activation, + formation, separation, public formalisation, legal marriage or legal + divorce, bind the event class to both charts' relevant promise, both + individually activated timing layers, pair interaction, transit/annual + corroboration and supplied relationship history. +7. In pair contacts, separate **what one person brings** from **where the other + person receives it**. A contact may be mutual in geometry yet asymmetric in + lived experience, benefit, cost, initiative and recovery. The private + dossier therefore indexes every directional overlay/contact under the + recipient's reader topics. A customer report must make both sides explicit; + it must not turn a contact into an equal exchange by default. +8. Keep D1 and D9 pair evidence distinct. The private dossier emits D1 in + `pair_interaction_evidence` and D9 in + `pair_interaction_evidence.d9_interaction_evidence`; both retain directional + overlays, angular contacts, rashi contacts and recipient-side indexing. D1 + describes the natal interaction field; D9 qualifies relationship and + commitment structure. Neither replaces the other or establishes an outcome. +9. Use the private `mutual_affection_activation` brief for the attraction and + closeness section. It keeps each recipient's D1 and D9 evidence separate so + the writer can explain reciprocal warmth-building conditions, pressure + triggers and one mutual practice without turning astrology into a tactic for + controlling another person. +10. Use `reader_section_brief` before writing. It binds each reader section to + its relevant module rows, pair lens and, where applicable, separate D1/D9 + recipient evidence. It is private selection guidance, not report prose and + not a relationship verdict. + +## Supplementary Lanes + +Star-relationship/Shukuyo, Western synastry/composite/Davison and a genuine +Prashna chart are not replacements for the six lines above. If calculated, +record them in the private dossier as separate sources. They may add an +unnamed experiential detail to a customer report only when they converge with +the primary Jyotish conclusion. They never determine a timing window or reverse +a primary-chain conclusion. diff --git a/skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py b/skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py new file mode 100644 index 00000000..f2bf6cb7 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py @@ -0,0 +1,1198 @@ +#!/usr/bin/env python3 +"""Assemble two native full-reading packets into a private synastry dossier. + +This adapter intentionally does not interpret a relationship or generate a +pass/fail result. It freezes comparable inputs, derives directional pair +contacts from the two native charts, calls the neutral ``marriage-match`` +product, and emits a complete source map for the report writers. Do not write +its input or output into a repository path. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from marriage_match import build_marriage_match_dossier # noqa: E402 +from aspects import _calc_pair_aspects # noqa: E402 +from narayana_dasha import _rasi_aspects # noqa: E402 +from relationship_analysis import classify_relationship_event_class # noqa: E402 + + +SCHEMA = "jyotish.full_synastry_dossier.v1" +PACKET_ADAPTER = Path(__file__).with_name("validate_full_synastry_packets.py") +_SPEC = importlib.util.spec_from_file_location("full_synastry_receipt", PACKET_ADAPTER) +_RECEIPT = importlib.util.module_from_spec(_SPEC) +assert _SPEC and _SPEC.loader +_SPEC.loader.exec_module(_RECEIPT) + + +MODULE_ROLES = { + "chart": "primary_natal_structure", + "functional_benefic_malefic": "primary_natal_structure", + "bhava_chalit": "primary_natal_structure", + "aspects": "pair_interaction_contacts", + "planetary_friendship": "pair_interaction_contacts", + "house_map": "primary_natal_structure", + "varga_full": "primary_varga_structure", + "d9_navamsa_expanded": "primary_varga_structure", + "vargottama": "primary_varga_structure", + "lagna_vargottamamsa": "primary_varga_structure", + "pushkara": "primary_varga_structure", + "pushkara_planets": "primary_varga_structure", + "varga_extended": "topic_bounded_varga_context", + "vimsopaka": "strength_and_weighting", + "shadbala": "strength_and_weighting", + "bhava_bala": "strength_and_weighting", + "ashtakavarga": "strength_and_weighting", + "dispositor_chains": "strength_and_weighting", + "final_dispositors": "strength_and_weighting", + "jaimini": "primary_jaimini_structure", + "special_lagnas": "primary_jaimini_structure", + "argala": "primary_jaimini_structure", + "rashi_tulya_navamsa": "primary_jaimini_structure", + "dasha": "primary_timing", + "dasha_sandhi": "primary_timing", + "narayana_dasha": "primary_timing", + "nakshatra_dasha": "primary_timing", + "yogini_dasha": "secondary_timing", + "ashtottari_dasha": "secondary_timing", + "kalachakra_dasha": "secondary_timing", + "navamsha_dasha": "secondary_timing", + "niryaana_shoola_dasha": "secondary_timing", + "shastihayani_dasha": "secondary_timing", + "dasa_convergence": "timing_convergence", + "rashi_dasha_profile_selection": "timing_profile_boundary", + "double_transit_pac": "primary_timing", + "transit_ll7l": "primary_timing", + "tajika_yogas": "annual_corroboration", + "tajika": "annual_corroboration", + "solar_return": "annual_corroboration", + "muntha": "annual_corroboration", + "sahams": "annual_corroboration", + "vivah_saham": "annual_corroboration", + "tithi_lord": "annual_corroboration", + "transit_positions": "transit_corroboration", + "transit_multi_reference": "transit_corroboration", + "bhrigu_pada_dasha": "parameter_sensitive_timing", + "marriage_counting": "bounded_context", + "trimshamsa_d30": "bounded_context", + "punarphoo": "observation_only", + "yogas_doshas": "pattern_and_pressure", + "yoga": "pattern_and_pressure", + "avasthas": "condition_and_capacity", + "sensitive_points": "derived_point_context", + "congregation": "pattern_and_pressure", + "nakshatra_advanced": "nakshatra_context", + "kp": "specialized_timing", + "relationship_strict_evidence": "relationship_book_evidence", + "unified_western_reading": "separate_western_support", + "prashna": "question_chart_context", +} + + +NARRATIVE_ROLE_PLAN = { + "primary_natal_structure": ( + ("relationship_thesis", "what_each_person_brings"), "primary_narrative_evidence", + ), + "pair_interaction_contacts": ( + ("emotional_safety", "attraction_and_closeness", "communication_conflict_and_repair"), "primary_narrative_evidence", + ), + "primary_varga_structure": ( + ("what_each_person_brings", "can_it_stay_steady"), "primary_narrative_evidence", + ), + "strength_and_weighting": ( + ("relationship_thesis", "can_it_stay_steady", "shared_life_gains_and_pressures"), "weight_or_conflict_check", + ), + "primary_jaimini_structure": ( + ("relationship_progression", "can_it_stay_steady"), "primary_narrative_evidence", + ), + "primary_timing": ( + ("relationship_progression", "relationship_weather"), "event_class_corroboration", + ), + "secondary_timing": ( + ("relationship_weather",), "secondary_corroboration_only", + ), + "timing_convergence": ( + ("relationship_weather",), "conflict_or_convergence_check", + ), + "timing_profile_boundary": ( + ("relationship_weather",), "parameter_boundary", + ), + "annual_corroboration": ( + ("relationship_weather", "relationship_progression"), "event_class_corroboration", + ), + "transit_corroboration": ( + ("relationship_weather",), "event_class_corroboration", + ), + "parameter_sensitive_timing": ( + ("relationship_weather",), "parameter_sensitive_corroboration", + ), + "bounded_context": ( + ("shared_life_gains_and_pressures", "can_it_stay_steady"), "bounded_context_only", + ), + "pattern_and_pressure": ( + ("communication_conflict_and_repair", "can_it_stay_steady"), "pressure_context_not_verdict", + ), + "condition_and_capacity": ( + ("what_each_person_brings", "can_it_stay_steady"), "condition_check", + ), + "derived_point_context": ( + ("relationship_weather",), "supporting_context_only", + ), + "nakshatra_context": ( + ("attraction_and_closeness", "relationship_weather"), "supporting_context_only", + ), + "specialized_timing": ( + ("relationship_weather",), "specialized_corroboration_only", + ), + "relationship_book_evidence": ( + ("relationship_thesis", "relationship_progression", "can_it_stay_steady"), "rule_grounding_only", + ), + "separate_western_support": ( + (), "private_supplementary_convergence_only", + ), + "question_chart_context": ( + (), "private_supplementary_convergence_only", + ), +} + + +RELATIONSHIP_LENSES = ( + { + "lane": "relationship_state", + "customer_question": "What is the central state of this relationship now: pull, mismatch, readiness or pause?", + "required_pair_tasks": ( + "D1/D9 mutual overlays and relationship axes", + "two-person Dasha/Narayana/Chara context comparison", + ), + }, + { + "lane": "emotional_safety", + "customer_question": "Do the two people feel emotionally safe, seen and at ease together?", + "required_pair_tasks": ( + "D1/D9 mutual overlays and relationship axes", + "lived interaction: emotional safety, attraction, communication and conflict pattern", + ), + }, + { + "lane": "attraction_and_closeness", + "customer_question": "How do attraction, affection, closeness and personal space develop between them?", + "required_pair_tasks": ( + "lived interaction: emotional safety, attraction, communication and conflict pattern", + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ), + }, + { + "lane": "communication_conflict_and_repair", + "customer_question": "How do they communicate, disagree, withdraw, reconnect and repair?", + "required_pair_tasks": ( + "lived interaction: emotional safety, attraction, communication and conflict pattern", + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ), + }, + { + "lane": "relationship_progression", + "customer_question": "What moves the relationship from attraction to partnership, public commitment or legal marriage?", + "required_pair_tasks": ( + "two-person Dasha/Narayana/Chara context comparison", + "secondary-dasha convergence and disagreement review", + "shared transit, Tajika, Saham and annual-window comparison", + ), + }, + { + "lane": "shared_life_gains_and_pressures", + "customer_question": "What becomes easier together, and where do home, money, work, family or private-space pressures accumulate?", + "required_pair_tasks": ( + "shared gain and cost: resources, home, work and private-space axes", + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ), + }, + { + "lane": "stability_and_long_term_repair", + "customer_question": "Can the relationship stay steady through responsibility, intimacy and repair?", + "required_pair_tasks": ( + "DK, UL, UL-second and A7/Darapada cross-comparison", + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ), + }, + { + "lane": "near_term_relationship_weather", + "customer_question": "What relationship stage is most supported in the next usable period?", + "required_pair_tasks": ( + "two-person Dasha/Narayana/Chara context comparison", + "shared transit, Tajika, Saham and annual-window comparison", + ), + }, +) + + +READER_SECTION_BRIEFS = ( + ("relationship_thesis", "relationship_state", ()), + ("what_each_person_brings", None, ()), + ("emotional_safety", "emotional_safety", ("emotional_safety_and_lived_experience",)), + ( + "attraction_and_closeness", + "attraction_and_closeness", + ("attraction_and_closeness", "pressure_conflict_and_recovery"), + ), + ( + "communication_conflict_and_repair", + "communication_conflict_and_repair", + ("communication_conflict_and_repair", "pressure_conflict_and_recovery"), + ), + ("relationship_progression", "relationship_progression", ("relationship_progression_and_commitment",)), + ( + "shared_life_gains_and_pressures", + "shared_life_gains_and_pressures", + ("shared_life_gains_and_pressures", "pressure_conflict_and_recovery"), + ), + ( + "can_it_stay_steady", + "stability_and_long_term_repair", + ("relationship_progression_and_commitment", "pressure_conflict_and_recovery"), + ), + ("relationship_weather", "near_term_relationship_weather", ()), +) + +INTERACTION_PLANETS = ( + "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", +) +RELATIONSHIP_AXIS_HOUSES = { + 1: "presence_and_first_impression", + 5: "romance_and_play", + 7: "partnership_and_commitment", + 8: "intimacy_and_shared_vulnerability", + 12: "privacy_space_and_recovery", +} + + +def _reader_topics_for_contact(planet: str | None, house: int | None) -> list[str]: + """Index raw contacts for report sections without assigning a verdict.""" + topics: set[str] = set() + if planet in {"Moon", "Venus"} or house in {4, 8, 12}: + topics.add("emotional_safety_and_lived_experience") + if planet in {"Venus", "Mars", "Moon"} or house in {5, 7, 8}: + topics.add("attraction_and_closeness") + if planet == "Mercury" or house in {3, 6}: + topics.add("communication_conflict_and_repair") + if planet in {"Mars", "Saturn", "Rahu", "Ketu"} or house in {6, 8, 12}: + topics.add("pressure_conflict_and_recovery") + if planet in {"Jupiter", "Saturn", "Sun"} or house in {2, 4, 10, 11}: + topics.add("shared_life_gains_and_pressures") + if planet in {"Jupiter", "Saturn", "Venus"} or house == 7: + topics.add("relationship_progression_and_commitment") + return sorted(topics) + + +def _recipient_experience_evidence( + overlays_a_to_b: list[dict[str, Any]], + overlays_b_to_a: list[dict[str, Any]], + angular_contacts: list[dict[str, Any]], + rashi_contacts: list[dict[str, Any]], +) -> dict[str, list[dict[str, Any]]]: + """Keep recipient-side evidence separate so similarity is never assumed.""" + recipients: dict[str, list[dict[str, Any]]] = {"person_a": [], "person_b": []} + for recipient, overlays in (("person_b", overlays_a_to_b), ("person_a", overlays_b_to_a)): + for overlay in overlays: + recipients[recipient].append({ + "evidence_type": "house_overlay", + "source_direction": overlay["direction"], + "source_planet": overlay["planet"], + "recipient_house": overlay["recipient_house"], + "reader_topics": _reader_topics_for_contact(overlay["planet"], overlay["recipient_house"]), + "source_path": "pair_interaction_evidence.*_overlays", + }) + for contact in angular_contacts: + direction = contact["direction"] + recipients_for_contact = ( + ("person_a", "person_b") if direction == "mutual_angle" + else ("person_b",) if direction == "person_a_to_person_b" + else ("person_a",) if direction == "person_b_to_person_a" + else () + ) + for recipient in recipients_for_contact: + source_planet = contact["person_a_planet"] if recipient == "person_b" else contact["person_b_planet"] + recipients[recipient].append({ + "evidence_type": "angular_contact", + "source_direction": direction, + "source_planet": source_planet, + "counterpart_planet": contact["person_b_planet"] if recipient == "person_b" else contact["person_a_planet"], + "aspect_type": contact["aspect_type"], + "reader_topics": _reader_topics_for_contact(source_planet, None), + "source_path": "pair_interaction_evidence.angular_contacts", + }) + for contact in rashi_contacts: + direction = contact["direction"] + recipients_for_contact = ( + ("person_a", "person_b") if direction == "mutual" + else ("person_b",) if direction == "person_a_to_person_b" + else ("person_a",) + ) + for recipient in recipients_for_contact: + source_planet = contact["person_a_planet"] if recipient == "person_b" else contact["person_b_planet"] + recipients[recipient].append({ + "evidence_type": "rashi_contact", + "source_direction": direction, + "source_planet": source_planet, + "counterpart_planet": contact["person_b_planet"] if recipient == "person_b" else contact["person_a_planet"], + "reader_topics": _reader_topics_for_contact(source_planet, None), + "source_path": "pair_interaction_evidence.jaimini_rashi_contacts", + }) + return recipients + +EVENT_CLASSES = ( + "romantic_activation", + "relationship_formation", + "separation_or_reconciliation", + "public_formalization", + "legal_marriage", + "legal_divorce", +) + + +def _packet(value: Mapping[str, Any]) -> Mapping[str, Any]: + nested = value.get("raw_full_reading") if isinstance(value, Mapping) else None + return nested if isinstance(nested, Mapping) else value + + +def _modules(packet: Mapping[str, Any]) -> Mapping[str, Any]: + value = packet.get("modules") if isinstance(packet, Mapping) else None + return value if isinstance(value, Mapping) else {} + + +def _longitude(value: Any) -> float | None: + if not isinstance(value, Mapping): + return None + for key in ("longitude", "degree_raw", "lon", "degree"): + candidate = value.get(key) + if isinstance(candidate, (int, float)) and not isinstance(candidate, bool): + return float(candidate) % 360.0 + return None + + +def chart_snapshot(packet: Mapping[str, Any]) -> dict[str, Any]: + """Extract only the calculated positions required by marriage-match.""" + chart = _modules(packet).get("chart") or packet.get("chart") or {} + chart = chart if isinstance(chart, Mapping) else {} + ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), Mapping) else {} + planets = chart.get("planets") if isinstance(chart.get("planets"), Mapping) else {} + snapshot: dict[str, Any] = {"ascendant": {}, "planets": {}} + asc_lon = _longitude(ascendant) + if asc_lon is not None: + snapshot["ascendant"] = {"longitude": asc_lon} + for planet in INTERACTION_PLANETS: + longitude = _longitude(planets.get(planet)) + if longitude is not None: + snapshot["planets"][planet] = {"longitude": longitude} + return snapshot + + +def _house_from_longitudes(longitude: float, ascendant: float) -> int: + return (int(longitude // 30) - int(ascendant // 30)) % 12 + 1 + + +def _d9_longitude(value: Any) -> float | None: + """Recover a D9 longitude from native sign-index and intra-sign output.""" + direct = _longitude(value) + if direct is not None: + return direct + if not isinstance(value, Mapping): + return None + sign_index = value.get("sign_idx") + degree_in_sign = value.get("degree_in_sign") + if ( + isinstance(sign_index, int) and not isinstance(sign_index, bool) + and 0 <= sign_index < 12 + and isinstance(degree_in_sign, (int, float)) and not isinstance(degree_in_sign, bool) + and 0.0 <= float(degree_in_sign) < 30.0 + ): + return (sign_index * 30.0 + float(degree_in_sign)) % 360.0 + return None + + +def _interaction_positions(packet: Mapping[str, Any], *, division: str = "D1") -> dict[str, Any]: + """Return calculated D1 or D9 positions needed for pair interaction facts.""" + modules = _modules(packet) + if division == "D9": + chart = modules.get("varga_full", {}).get("D9_Navamsa", {}) + longitude_for = _d9_longitude + source_paths = ["modules.varga_full.D9_Navamsa"] + else: + chart = modules.get("chart") or packet.get("chart") or {} + longitude_for = _longitude + source_paths = ["modules.chart.ascendant", "modules.chart.planets"] + chart = chart if isinstance(chart, Mapping) else {} + asc_key = "Ascendant" if division == "D9" else "ascendant" + ascendant = chart.get(asc_key) if isinstance(chart.get(asc_key), Mapping) else {} + planets = chart if division == "D9" else chart.get("planets") if isinstance(chart.get("planets"), Mapping) else {} + asc_lon = longitude_for(ascendant) + longitudes = { + planet: longitude + for planet in INTERACTION_PLANETS + if (longitude := longitude_for(planets.get(planet))) is not None + } + return { + "division": division, + "source_paths": source_paths, + "ascendant_longitude": asc_lon, + "planet_longitudes": longitudes, + } + + +def _cross_house_overlays( + source: Mapping[str, Any], recipient: Mapping[str, Any], direction: str, +) -> list[dict[str, Any]]: + recipient_asc = recipient.get("ascendant_longitude") + if not isinstance(recipient_asc, (int, float)): + return [] + overlays = [] + for planet, longitude in source.get("planet_longitudes", {}).items(): + house = _house_from_longitudes(float(longitude), float(recipient_asc)) + overlays.append({ + "direction": direction, + "planet": planet, + "planet_longitude": round(float(longitude), 6), + "recipient_house": house, + "relationship_axis": RELATIONSHIP_AXIS_HOUSES.get(house), + }) + return overlays + + +def _cross_angular_contacts( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], +) -> list[dict[str, Any]]: + contacts = [] + for a_planet, a_longitude in person_a.get("planet_longitudes", {}).items(): + for b_planet, b_longitude in person_b.get("planet_longitudes", {}).items(): + for contact in _calc_pair_aspects(a_planet, float(a_longitude), b_planet, float(b_longitude)): + aspect_type = str(contact.get("type")) + if aspect_type in {"conjunction", "opposition"}: + direction = "mutual_angle" + elif a_planet != b_planet and aspect_type.startswith(f"{a_planet}_"): + direction = "person_a_to_person_b" + elif a_planet != b_planet and aspect_type.startswith(f"{b_planet}_"): + direction = "person_b_to_person_a" + else: + # A same-named pair (for example Mars-to-Mars) cannot be + # oriented from the aspect label alone. The native helper + # preserves the directed angular distance for special + # graha aspects, so use it to recover the source side. + directed_from_a = (float(b_longitude) - float(a_longitude)) % 360.0 + directed_from_b = (float(a_longitude) - float(b_longitude)) % 360.0 + actual = float(contact.get("actual_diff", -1.0)) % 360.0 + if abs(actual - directed_from_a) < 0.02: + direction = "person_a_to_person_b" + elif abs(actual - directed_from_b) < 0.02: + direction = "person_b_to_person_a" + else: + direction = "undetermined_direction" + contacts.append({ + "person_a_planet": a_planet, + "person_b_planet": b_planet, + "direction": direction, + "aspect_type": aspect_type, + "aspect_degree": contact.get("aspect_degree"), + "orb_degrees": contact.get("orb"), + "orb_category": contact.get("orb_category"), + "strength": contact.get("strength"), + }) + return contacts + + +def _cross_rashi_contacts( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], +) -> list[dict[str, Any]]: + contacts = [] + for a_planet, a_longitude in person_a.get("planet_longitudes", {}).items(): + a_sign = int(float(a_longitude) // 30) % 12 + for b_planet, b_longitude in person_b.get("planet_longitudes", {}).items(): + b_sign = int(float(b_longitude) // 30) % 12 + a_to_b = _rasi_aspects(a_sign, b_sign) + b_to_a = _rasi_aspects(b_sign, a_sign) + if a_to_b or b_to_a: + contacts.append({ + "person_a_planet": a_planet, + "person_b_planet": b_planet, + "person_a_sign_index": a_sign, + "person_b_sign_index": b_sign, + "direction": "mutual" if a_to_b and b_to_a else "person_a_to_person_b" if a_to_b else "person_b_to_person_a", + }) + return contacts + + +def _build_divisional_pair_interaction_evidence( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], + *, division: str, +) -> dict[str, Any]: + """Calculate raw D1/D9 contacts without interpreting compatibility.""" + positions_a = _interaction_positions(person_a, division=division) + positions_b = _interaction_positions(person_b, division=division) + has_ascendants = all( + isinstance(positions.get("ascendant_longitude"), (int, float)) + for positions in (positions_a, positions_b) + ) + has_planets = bool(positions_a["planet_longitudes"] and positions_b["planet_longitudes"]) + status = "executed" if has_ascendants and has_planets else "blocked" + overlays_a_to_b = _cross_house_overlays(positions_a, positions_b, "person_a_to_person_b") if has_ascendants else [] + overlays_b_to_a = _cross_house_overlays(positions_b, positions_a, "person_b_to_person_a") if has_ascendants else [] + angular_contacts = _cross_angular_contacts(positions_a, positions_b) if has_planets else [] + rashi_contacts = _cross_rashi_contacts(positions_a, positions_b) if has_planets else [] + return { + "status": status, + "division": division, + "source_paths": positions_a["source_paths"], + "person_a_to_person_b_overlays": overlays_a_to_b, + "person_b_to_person_a_overlays": overlays_b_to_a, + "angular_contacts": angular_contacts, + "jaimini_rashi_contacts": rashi_contacts, + "recipient_experience_evidence": _recipient_experience_evidence( + overlays_a_to_b, overlays_b_to_a, angular_contacts, rashi_contacts, + ), + "boundary": ( + f"This is a directional raw-contact layer from {division}, not a compatibility score or a relationship verdict. " + "Recipient-side topic indexing prevents a false symmetry assumption: each person's felt benefit, cost and response must be written from their own received evidence, then checked against natal structure, D9/Jaimini and timing." + ), + } + + +def build_pair_interaction_evidence( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], +) -> dict[str, Any]: + """Calculate separate D1 and D9 pair-contact evidence without a verdict.""" + d1 = _build_divisional_pair_interaction_evidence(person_a, person_b, division="D1") + d9 = _build_divisional_pair_interaction_evidence(person_a, person_b, division="D9") + # Retain the D1 keys for existing dossier consumers. D9 remains distinct + # because it cannot be substituted for the lived D1 interaction layer. + return { + **d1, + "d9_interaction_evidence": d9, + "division_boundary": ( + "D1 contacts describe the natal interaction field; D9 contacts describe the divisional relationship and commitment field. " + "Do not average them, overwrite one with the other, or call either a relationship outcome." + ), + } + + +def build_mutual_affection_activation_brief( + interaction: Mapping[str, Any], +) -> dict[str, Any]: + """Prepare reciprocal-care evidence without turning it into a love tactic.""" + d9 = interaction.get("d9_interaction_evidence") if isinstance(interaction, Mapping) else None + d9 = d9 if isinstance(d9, Mapping) else {} + recipients = {} + relevant_topics = { + "emotional_safety_and_lived_experience", "attraction_and_closeness", + "communication_conflict_and_repair", "pressure_conflict_and_recovery", + "relationship_progression_and_commitment", + } + for recipient in ("person_a", "person_b"): + d1_rows = (interaction.get("recipient_experience_evidence", {}) or {}).get(recipient, []) + d9_rows = (d9.get("recipient_experience_evidence", {}) or {}).get(recipient, []) + recipients[recipient] = { + "d1_evidence": [ + row for row in d1_rows if isinstance(row, Mapping) + and any(topic in relevant_topics for topic in row.get("reader_topics", ())) + ], + "d9_evidence": [ + row for row in d9_rows if isinstance(row, Mapping) + and any(topic in relevant_topics for topic in row.get("reader_topics", ())) + ], + "writer_obligations": [ + "what the counterpart can do that helps this recipient feel cherished, safe or warmly connected", + "what interaction condition helps this recipient maintain closeness without losing agency or space", + "what concrete pattern can leave this recipient pressured, unseen or likely to withdraw", + "one mutual practice tied to both recipients' evidence, not a tactic for extracting affection", + ], + } + return { + "status": "executed" if all(recipients[person]["d1_evidence"] for person in recipients) else "blocked", + "recipients": recipients, + "boundary": ( + "This supports reciprocal care and observable relationship practices. It does not infer consent, sexuality, fidelity, " + "or a method for making someone love another person. D1 and D9 evidence remain separate." + ), + } + + +def _inventory(packet: Mapping[str, Any], person: str) -> list[dict[str, str]]: + rows = [] + for module_id, value in sorted(_modules(packet).items()): + status = _RECEIPT._status(value) + rows.append({ + "person": person, + "module_id": str(module_id), + "source_path": f"modules.{module_id}", + "status": status, + "relationship_role": MODULE_ROLES.get(str(module_id), "retained_unclassified"), + }) + return rows + + +def _source_map(receipt: Mapping[str, Any]) -> list[dict[str, Any]]: + rows = [] + for row in receipt.get("individual_technique_audit", []): + rows.append({ + "kind": "individual_technique", + "label": row.get("technique"), + "status": row.get("status"), + "source_path": [ + (row.get("person_a") or {}).get("source_path"), + (row.get("person_b") or {}).get("source_path"), + ], + "person_a_source": (row.get("person_a") or {}).get("source_path"), + "person_b_source": (row.get("person_b") or {}).get("source_path"), + "report_use": "compare before narrative", + }) + for row in receipt.get("pair_execution_tasks", []): + rows.append({ + "kind": "pair_comparison", + "label": row.get("task"), + "status": row.get("status"), + "source_inputs": row.get("required_inputs", []), + "report_use": row.get("boundary"), + }) + return rows + + +def _module_narrative_plan(packet: Mapping[str, Any], person: str) -> list[dict[str, Any]]: + """Map every computed source module to a reader question or private boundary.""" + rows = [] + for module_id, value in sorted(_modules(packet).items()): + role = MODULE_ROLES.get(str(module_id), "retained_unclassified") + sections, disposition = NARRATIVE_ROLE_PLAN.get( + role, ((), "private_review_required_before_omission"), + ) + rows.append({ + "person": person, + "module_id": str(module_id), + "source_path": f"modules.{module_id}", + "status": _RECEIPT._status(value), + "relationship_role": role, + "reader_sections": list(sections), + "disposition": disposition, + }) + return rows + + +def _relationship_lens_status(receipt: Mapping[str, Any]) -> list[dict[str, Any]]: + """Expose complete pair-evidence readiness by reader-relevant question.""" + task_status = { + str(row.get("task")): str(row.get("status")) + for row in receipt.get("pair_execution_tasks", []) + if isinstance(row, Mapping) + } + lenses = [] + for definition in RELATIONSHIP_LENSES: + tasks = list(definition["required_pair_tasks"]) + statuses = {task: task_status.get(task, "blocked") for task in tasks} + if any(status == "blocked" for status in statuses.values()): + status = "blocked" + elif any(status == "parameter_sensitive" for status in statuses.values()): + status = "parameter_sensitive" + elif all(status == "ready_for_comparison" for status in statuses.values()): + status = "ready_for_comparison" + else: + status = "not_applicable" + lenses.append({ + "lane": definition["lane"], + "customer_question": definition["customer_question"], + "status": status, + "required_pair_tasks": statuses, + "boundary": "Read both people separately, then compare direction, benefit, cost and condition. This is evidence readiness, not a relationship verdict.", + }) + return lenses + + +def _recipient_rows_for_topics( + interaction: Mapping[str, Any], topics: tuple[str, ...], +) -> dict[str, list[dict[str, Any]]]: + """Select private recipient-side evidence without collapsing directions.""" + by_recipient = interaction.get("recipient_experience_evidence") + by_recipient = by_recipient if isinstance(by_recipient, Mapping) else {} + selected: dict[str, list[dict[str, Any]]] = {} + for recipient in ("person_a", "person_b"): + rows = by_recipient.get(recipient, []) + selected[recipient] = [ + row for row in rows + if isinstance(row, Mapping) + and any(topic in row.get("reader_topics", ()) for topic in topics) + ] + return selected + + +def build_reader_section_brief( + narrative_plan: list[dict[str, Any]], + lenses: list[dict[str, Any]], + interaction: Mapping[str, Any], + mutual_affection: Mapping[str, Any], + timing: Mapping[str, Any], + event_gate: Mapping[str, Any], +) -> list[dict[str, Any]]: + """Make every reader section traceable to private evidence before prose.""" + lens_by_lane = {str(row.get("lane")): row for row in lenses if isinstance(row, Mapping)} + d9 = interaction.get("d9_interaction_evidence") + d9 = d9 if isinstance(d9, Mapping) else {} + rows: list[dict[str, Any]] = [] + for section, lens_lane, topics in READER_SECTION_BRIEFS: + source_modules = [ + { + "person": row["person"], + "module_id": row["module_id"], + "source_path": row["source_path"], + "status": row["status"], + "disposition": row["disposition"], + } + for row in narrative_plan + if section in row.get("reader_sections", ()) + ] + lens = lens_by_lane.get(lens_lane) if lens_lane else None + row: dict[str, Any] = { + "section": section, + "status": lens.get("status") if isinstance(lens, Mapping) else "source_bounded", + "source_modules": source_modules, + "pair_lens": lens, + "boundary": ( + "Use only as a private evidence-selection brief. Write each person's received experience separately; " + "source availability is not a positive relationship conclusion." + ), + } + if topics: + row["d1_recipient_evidence"] = _recipient_rows_for_topics(interaction, topics) + row["d9_recipient_evidence"] = _recipient_rows_for_topics(d9, topics) + if section == "attraction_and_closeness": + row["reciprocal_care"] = mutual_affection + if section == "relationship_weather": + row["parallel_personal_timing"] = timing + row["event_resonance_gate"] = event_gate + rows.append(row) + return rows + + +def _parse_period_boundary(value: Any) -> datetime | None: + """Parse an engine-emitted period boundary without manufacturing one.""" + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return None + + +def _period_boundary_record( + period: Any, + *, + system: str, + level: str, + source_path: str, +) -> dict[str, Any] | None: + """Keep a dated native period only when both engine boundaries are usable.""" + if not isinstance(period, Mapping): + return None + start_raw = period.get("start_datetime") or period.get("start") + end_raw = period.get("end_datetime") or period.get("end") + start, end = _parse_period_boundary(start_raw), _parse_period_boundary(end_raw) + if start is None or end is None or start >= end: + return None + label = period.get("lord") or period.get("sign") + if not isinstance(label, str) or not label: + return None + return { + "system": system, + "level": level, + "label": label, + "start": start.isoformat(timespec="seconds"), + "end": end.isoformat(timespec="seconds"), + "source_path": source_path, + } + + +def _current_vimshottari_periods(packet: Mapping[str, Any]) -> dict[str, Any]: + """Read the engine's active MD/AD/PD boundaries; never rebuild Dasha order.""" + dasha = _modules(packet).get("dasha") + if not isinstance(dasha, Mapping): + return {"status": "blocked", "reason": "modules.dasha_missing", "periods": []} + current = dasha.get("current_dasha") + if not isinstance(current, Mapping): + return {"status": "blocked", "reason": "current_dasha_missing", "periods": []} + periods: list[dict[str, Any]] = [] + md = _period_boundary_record( + current, + system="vimshottari", + level="mahadasha", + source_path="modules.dasha.current_dasha", + ) + if md: + periods.append(md) + ad = current.get("antardasha") + if not isinstance(ad, Mapping): + ad = next( + ( + item for item in current.get("antardasha_timeline", []) + if isinstance(item, Mapping) and item.get("is_current") + ), + None, + ) + ad_record = _period_boundary_record( + ad, + system="vimshottari", + level="antardasha", + source_path="modules.dasha.current_dasha.antardasha", + ) + if ad_record: + periods.append(ad_record) + pd = None + if isinstance(ad, Mapping): + pd = next( + ( + item for item in ad.get("pratyantar_dasha_timeline", []) + if isinstance(item, Mapping) and item.get("is_current") + ), + None, + ) + pd_record = _period_boundary_record( + pd, + system="vimshottari", + level="pratyantar", + source_path="modules.dasha.current_dasha.antardasha.pratyantar_dasha_timeline", + ) + if pd_record: + periods.append(pd_record) + return { + "status": "executed" if periods else "blocked", + "reason": None if periods else "current_dasha_has_no_valid_dated_period", + "reference_date": dasha.get("reference_date"), + "periods": periods, + } + + +def build_parallel_timing_context( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], +) -> dict[str, Any]: + """Preserve two active period contexts without treating calendar overlap as resonance.""" + timing_a = _current_vimshottari_periods(person_a) + timing_b = _current_vimshottari_periods(person_b) + status = "executed" if timing_a["periods"] and timing_b["periods"] else "blocked" + references = {timing_a.get("reference_date"), timing_b.get("reference_date")} - {None} + return { + "status": status, + "reference_dates": {"person_a": timing_a.get("reference_date"), "person_b": timing_b.get("reference_date")}, + "reference_alignment": "matched" if len(references) == 1 else "different_or_missing", + "person_a_active_periods": timing_a["periods"], + "person_b_active_periods": timing_b["periods"], + "systems": { + "vimshottari": { + "person_a": timing_a["status"], + "person_b": timing_b["status"], + "source_paths": ["modules.dasha.current_dasha", "modules.dasha.current_dasha.antardasha"], + }, + "narayana": { + "status": "not_used_for_date_intersection", + "reason": "native full-reading packet supplies sign-period structure but not absolute start/end dates for this comparison adapter", + }, + }, + "event_resonance_requirement": { + "status": "not_established_by_parallel_periods", + "required_for_any_event_class": [ + "an explicit event class: romantic_activation, relationship_formation, separation, public_formalization, legal_marriage or legal_divorce", + "both people have the event-class-relevant natal and D9/UL/DK/A7 promise assessed", + "both people have separately activated event-class timing, not merely concurrent calendar dates", + "the pair interaction and the relevant transit/annual evidence converge", + "current relationship status or event history is supplied where the question depends on it", + ], + }, + "boundary": ( + "The two date ranges are separate personal contexts. Never calculate or present their calendar overlap as a shared relationship window, " + "emotional resonance, equal benefit/cost, or evidence that a relationship will begin, separate, reconcile, commit, marry or divorce. " + "A relationship-event judgment requires the explicit event class and all listed resonance requirements." + ), + } + + +def _strict_stage_status(value: Any) -> str: + """Normalize the strict-workflow state without upgrading weak evidence.""" + if not isinstance(value, Mapping): + return "missing" + raw = str(value.get("status", "")).strip().lower() + if raw in {"present", "used", "executed", "available"}: + return "present" + if raw in {"weak", "partial", "parameter_sensitive", "observation_only"}: + return "parameter_sensitive" + return "missing" + + +def _darakaraka(packet: Mapping[str, Any]) -> str | None: + jaimini = _modules(packet).get("jaimini") + jaimini = jaimini if isinstance(jaimini, Mapping) else {} + for path in ( + ("chara_karaka_standard", "karakas", "DK"), + ("chara_karaka_8", "karaka_table_8", "Darakaraka"), + ("chara_karaka_7", "karaka_table", "Darakaraka"), + ): + value: Any = jaimini + for key in path: + value = value.get(key) if isinstance(value, Mapping) else None + planet = value.get("planet") if isinstance(value, Mapping) else None + if isinstance(planet, str) and planet: + return planet + return None + + +def _native_event_candidate(packet: Mapping[str, Any]) -> dict[str, Any]: + """Invoke the existing native classifier; retain its observation boundary.""" + modules = _modules(packet) + chart = modules.get("chart") + chart = chart if isinstance(chart, Mapping) else {} + planets = chart.get("planets") if isinstance(chart.get("planets"), Mapping) else {} + ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), Mapping) else {} + asc_sign = ascendant.get("sign") + dasha = modules.get("dasha") + dasha = dasha if isinstance(dasha, Mapping) else {} + current_dasha = dasha.get("current_dasha") + special_lagnas = modules.get("special_lagnas") + special_lagnas = special_lagnas if isinstance(special_lagnas, Mapping) else {} + ul = special_lagnas.get("Upapada_Lagna") + ul_sign = ul.get("sign") if isinstance(ul, Mapping) else None + transits = modules.get("transit_positions") + transits = transits if isinstance(transits, Mapping) else {} + transit_planets = transits.get("planets") if isinstance(transits.get("planets"), Mapping) else {} + saturn = transit_planets.get("Saturn") if isinstance(transit_planets.get("Saturn"), Mapping) else {} + venus = transit_planets.get("Venus") if isinstance(transit_planets.get("Venus"), Mapping) else {} + if not planets or not isinstance(asc_sign, str) or not isinstance(current_dasha, Mapping): + return {"status": "blocked", "reason": "chart_or_current_dasha_missing", "event_class": None} + result = classify_relationship_event_class( + dict(planets), + asc_sign=asc_sign, + dasha_info=dict(current_dasha), + ul_sign=ul_sign if isinstance(ul_sign, str) else None, + darakaraka=_darakaraka(packet), + transit_saturn_longitude=_longitude(saturn), + transit_venus_longitude=_longitude(venus), + ) + return { + "status": str(result.get("claim_status") or "observation_only"), + "event_class": result.get("event_class") if result.get("event_class") in EVENT_CLASSES else None, + "active_classes": result.get("active_classes") if isinstance(result.get("active_classes"), list) else [], + "source_path": "native:relationship_analysis.classify_relationship_event_class", + "result": result, + } + + +def _individual_event_evidence(packet: Mapping[str, Any]) -> dict[str, Any]: + """Read explicit event evidence while rejecting generic single-chart scores.""" + strict = _modules(packet).get("relationship_strict_evidence") + if not isinstance(strict, Mapping): + return { + "status": "blocked", + "reason": "modules.relationship_strict_evidence_missing", + "event_class": None, + } + judgement = strict.get("event_judgement") + judgement = judgement if isinstance(judgement, Mapping) else {} + contract = strict.get("prediction_boundary_contract") + contract = contract if isinstance(contract, Mapping) else {} + declared = judgement.get("event_class") or judgement.get("dominant_label") + declared_event_class = declared if declared in EVENT_CLASSES else None + native_candidate = _native_event_candidate(packet) + native_event_class = native_candidate.get("event_class") + event_class = declared_event_class or native_event_class + stages = { + stage: _strict_stage_status(contract.get(stage)) + for stage in ("promise", "activation", "manifestation", "label") + } + if declared_event_class and all(state == "present" for state in stages.values()): + status = "event_specific_evidence_present" + elif native_event_class: + status = "event_class_observation_only" + elif any(state != "missing" for state in stages.values()): + status = "event_class_not_established" + else: + status = "blocked" + return { + "status": status, + "event_class": event_class, + "event_class_source": ( + "strict_event_label" if declared_event_class else "native_observation_only" if native_event_class else None + ), + "native_event_candidate": native_candidate, + "stages": stages, + "source_paths": [ + "modules.relationship_strict_evidence.event_judgement", + "modules.relationship_strict_evidence.prediction_boundary_contract", + ], + "ignored_non_event_fields": { + "verdict": judgement.get("verdict"), + "score": judgement.get("score"), + "reason": "A generic single-chart score or window verdict is not an explicit relationship-event class and cannot establish pair resonance.", + }, + } + + +def build_event_resonance_gate( + person_a: Mapping[str, Any], person_b: Mapping[str, Any], +) -> dict[str, Any]: + """Record whether a named event may be assessed, never whether it occurred.""" + evidence_a = _individual_event_evidence(person_a) + evidence_b = _individual_event_evidence(person_b) + rows = [] + for event_class in EVENT_CLASSES: + a_matches = evidence_a.get("event_class") == event_class + b_matches = evidence_b.get("event_class") == event_class + both_specific = ( + evidence_a.get("status") == "event_specific_evidence_present" + and evidence_b.get("status") == "event_specific_evidence_present" + ) + both_observation = ( + evidence_a.get("status") == "event_class_observation_only" + and evidence_b.get("status") == "event_class_observation_only" + ) + if a_matches and b_matches and both_specific: + status = "candidate_alignment_requires_event_history" + elif a_matches and b_matches and both_observation: + status = "parallel_observation_only" + elif a_matches or b_matches: + status = "one_sided_or_incomplete" + else: + status = "not_established" + rows.append({ + "event_class": event_class, + "status": status, + "person_a_event_class": evidence_a.get("event_class"), + "person_b_event_class": evidence_b.get("event_class"), + "required_before_any_resonance_claim": [ + "both people independently meet the event-specific promise, activation, manifestation and label gates", + "the pair interaction and relevant transit/annual evidence converge for the same event class", + "the real relationship state or dated event history is supplied when claiming that an event occurred", + ], + }) + return { + "status": "executed" if evidence_a.get("status") != "blocked" and evidence_b.get("status") != "blocked" else "blocked", + "person_a": evidence_a, + "person_b": evidence_b, + "event_classes": rows, + "boundary": ( + "This gate never asserts that two people share an event. It rejects generic single-chart scores and requires a named event class. " + "Even a candidate alignment requires pair interaction, transit/annual corroboration and real history before reporting a relationship event as having occurred." + ), + } + + +def build_full_synastry_dossier( + person_a: Mapping[str, Any], + person_b: Mapping[str, Any], + *, + profile_id: str = "practitioner_consensus_v1", +) -> dict[str, Any]: + """Build a non-narrative delivery packet for the full synastry Skill.""" + packet_a, packet_b = _packet(person_a), _packet(person_b) + receipt = _RECEIPT.build_execution_receipt(packet_a, packet_b) + snapshot_a, snapshot_b = chart_snapshot(packet_a), chart_snapshot(packet_b) + pair_interaction_evidence = build_pair_interaction_evidence(packet_a, packet_b) + mutual_affection_activation = build_mutual_affection_activation_brief(pair_interaction_evidence) + parallel_timing_context = build_parallel_timing_context(packet_a, packet_b) + event_resonance_gate = build_event_resonance_gate(packet_a, packet_b) + module_narrative_plan = ( + _module_narrative_plan(packet_a, "person_a") + + _module_narrative_plan(packet_b, "person_b") + ) + relationship_lenses = _relationship_lens_status(receipt) + reader_section_brief = build_reader_section_brief( + module_narrative_plan, + relationship_lenses, + pair_interaction_evidence, + mutual_affection_activation, + parallel_timing_context, + event_resonance_gate, + ) + required = ("Moon", "Venus", "Mars") + snapshot_ready = all( + snapshot.get("ascendant") and all(planet in snapshot.get("planets", {}) for planet in required) + for snapshot in (snapshot_a, snapshot_b) + ) + product: dict[str, Any] + if snapshot_ready: + product = build_marriage_match_dossier( + snapshot_a, + snapshot_b, + relationship_type="romantic_or_marriage", + profile_id=profile_id, + ) + else: + product = { + "status": "blocked", + "reason": "chart_snapshot_missing_ascendant_moon_venus_or_mars", + "boundary": "The matching layer is absent; do not substitute a legacy score or infer it from prose.", + } + primary_tasks = { + "D1/D9 mutual overlays and relationship axes", + "DK, UL, UL-second and A7/Darapada cross-comparison", + "two-person Dasha/Narayana/Chara context comparison", + } + primary_ready = all( + row.get("status") == "ready_for_comparison" + for row in receipt.get("pair_execution_tasks", []) + if row.get("task") in primary_tasks + ) + return { + "schema": SCHEMA, + "calculation_profile": receipt["calculation_profile"], + "profile_gate": receipt["profile_gate"], + "delivery_readiness": "ready_for_narrative" if receipt["profile_gate"] == "passed" and primary_ready else "blocked", + "delivery_readiness_reason": ( + "both chart profiles match and all primary pair lanes have comparable inputs" + if receipt["profile_gate"] == "passed" and primary_ready + else "resolve the profile gate and every primary pair-comparison input before writing a full conclusion" + ), + "execution_receipt": receipt, + "marriage_match": product, + "chart_snapshots": {"person_a": snapshot_a, "person_b": snapshot_b}, + "pair_interaction_evidence": pair_interaction_evidence, + "mutual_affection_activation": mutual_affection_activation, + "parallel_timing_context": parallel_timing_context, + "event_resonance_gate": event_resonance_gate, + "module_inventory": _inventory(packet_a, "person_a") + _inventory(packet_b, "person_b"), + "module_narrative_plan": module_narrative_plan, + "source_map": _source_map(receipt), + "relationship_lenses": relationship_lenses, + "reader_section_brief": reader_section_brief, + "writing_contract": "skills/jyotish-full-synastry-reading/references/chinese-synastry-writing-contract.md", + "privacy_boundary": "Private runtime artifact only. Do not commit input packets, dossier output, labels, birth data or relationship history.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build a private full-synastry evidence dossier from two full-reading packets.") + parser.add_argument("--person-a", required=True, help="Private JSON full-reading or customer export packet for person A") + parser.add_argument("--person-b", required=True, help="Private JSON full-reading or customer export packet for person B") + parser.add_argument("--profile", default="practitioner_consensus_v1", choices=("practitioner_consensus_v1", "raman_muhurtha_v1")) + parser.add_argument("--output", help="Private output path. Never use a repository directory.") + args = parser.parse_args() + with Path(args.person_a).open(encoding="utf-8") as handle: + person_a = json.load(handle) + with Path(args.person_b).open(encoding="utf-8") as handle: + person_b = json.load(handle) + dossier = build_full_synastry_dossier(person_a, person_b, profile_id=args.profile) + encoded = json.dumps(dossier, ensure_ascii=False, indent=2) + if args.output: + Path(args.output).write_text(encoded + "\n", encoding="utf-8") + else: + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py b/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py new file mode 100644 index 00000000..a06cf37f --- /dev/null +++ b/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Compile two independently written private synastry drafts for delivery. + +This is a report compiler, not an astrology interpreter or a translation +service. It accepts separately authored Chinese and English section payloads, +binds them to the full-synastry dossier, and writes only after the existing +bilingual delivery gate passes. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[3] +VALIDATOR_PATH = Path(__file__).with_name("validate_bilingual_synastry_delivery.py") +EXPECTED_COMPOSITION = "independent_from_structured_dossier" +SECTION_KEYS = ( + "relationship_thesis", + "what_each_person_brings", + "emotional_safety", + "attraction_and_closeness", + "communication_conflict_and_repair", + "relationship_progression", + "shared_life_gains_and_pressures", + "can_it_stay_steady", + "relationship_weather", +) + + +def _load_module(name: str, path: Path) -> Any: + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_VALIDATOR = _load_module("full_synastry_bilingual_delivery", VALIDATOR_PATH) +ZH_HEADINGS = dict(zip(SECTION_KEYS, _VALIDATOR.ZH_HEADINGS, strict=True)) +EN_HEADINGS = dict(zip(SECTION_KEYS, _VALIDATOR.EN_HEADINGS, strict=True)) + + +def _private_output_directory(value: str) -> Path: + path = Path(value).expanduser().resolve() + try: + path.relative_to(ROOT) + except ValueError: + return path + raise ValueError("--output-dir must be outside the repository because it can contain private relationship information") + + +def _payload(value: Mapping[str, Any], *, language: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{language} section payload must be a JSON object") + if value.get("language") != language: + raise ValueError(f"{language} section payload must declare language={language!r}") + if value.get("composition") != EXPECTED_COMPOSITION: + raise ValueError(f"{language} section payload must declare independent composition from the structured dossier") + title = value.get("title", "") + if not isinstance(title, str): + raise ValueError(f"{language} title must be text") + sections = value.get("sections") + if not isinstance(sections, Mapping): + raise ValueError(f"{language} payload requires a sections object") + missing = [key for key in SECTION_KEYS if not isinstance(sections.get(key), str) or not sections[key].strip()] + unknown = sorted(set(sections) - set(SECTION_KEYS)) + if missing or unknown: + detail = [] + if missing: + detail.append("missing=" + ", ".join(missing)) + if unknown: + detail.append("unknown=" + ", ".join(unknown)) + raise ValueError(f"{language} section payload keys are invalid: {'; '.join(detail)}") + return {"title": title.strip(), "sections": {key: sections[key].strip() for key in SECTION_KEYS}} + + +def _dossier_ready(dossier: Mapping[str, Any]) -> None: + if not isinstance(dossier, Mapping): + raise ValueError("dossier must be a JSON object") + if dossier.get("schema") != "jyotish.full_synastry_dossier.v1": + raise ValueError("dossier schema is not jyotish.full_synastry_dossier.v1") + if dossier.get("delivery_readiness") != "ready_for_narrative": + raise ValueError("dossier is not ready_for_narrative") + brief = dossier.get("reader_section_brief") + if not isinstance(brief, list): + raise ValueError("dossier has no reader_section_brief") + actual = {row.get("section") for row in brief if isinstance(row, Mapping)} + if actual != set(SECTION_KEYS): + raise ValueError("dossier reader_section_brief does not cover every customer section") + + +def _render_markdown(payload: Mapping[str, Any], headings: Mapping[str, str]) -> str: + blocks = [] + if payload["title"]: + blocks.append(f"# {payload['title']}") + for key in SECTION_KEYS: + blocks.append(f"## {headings[key]}\n\n{payload['sections'][key]}") + return "\n\n".join(blocks).strip() + "\n" + + +def compile_delivery( + dossier: Mapping[str, Any], chinese_payload: Mapping[str, Any], english_payload: Mapping[str, Any], +) -> dict[str, Any]: + """Compile independently authored reports and reject a failed delivery in memory.""" + _dossier_ready(dossier) + chinese = _render_markdown(_payload(chinese_payload, language="zh"), ZH_HEADINGS) + english = _render_markdown(_payload(english_payload, language="en"), EN_HEADINGS) + receipt = _VALIDATOR.build_delivery_receipt(dossier, chinese, english) + if receipt.get("status") != "passed": + raise ValueError("bilingual synastry delivery gate failed; inspect the private receipt before writing any report") + return {"chinese_markdown": chinese, "english_markdown": english, "delivery_receipt": receipt} + + +def _publish_delivery_bundle(output_dir: Path, compiled: Mapping[str, Any]) -> None: + """Publish all three files together; never overwrite an existing delivery.""" + if output_dir.exists(): + raise ValueError("--output-dir already exists; choose a new private release directory") + output_dir.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".synastry-delivery-", dir=output_dir.parent)) + try: + (staging / "Chinese-Full-Synastry-Report.md").write_text(compiled["chinese_markdown"], encoding="utf-8") + (staging / "English-Full-Synastry-Report.md").write_text(compiled["english_markdown"], encoding="utf-8") + (staging / "bilingual-delivery-receipt.json").write_text( + json.dumps(compiled["delivery_receipt"], ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + staging.replace(output_dir) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description="Compile private, independently written full-synastry Markdown delivery.") + parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON") + parser.add_argument("--chinese-sections", required=True, help="Private independent Chinese section payload JSON") + parser.add_argument("--english-sections", required=True, help="Private independent English section payload JSON") + parser.add_argument("--output-dir", required=True, help="Private directory outside the repository") + parser.add_argument("--dry-run", action="store_true", help="Validate and print the intended delivery without writing files") + args = parser.parse_args() + + output_dir = _private_output_directory(args.output_dir) + dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8")) + chinese_payload = json.loads(Path(args.chinese_sections).read_text(encoding="utf-8")) + english_payload = json.loads(Path(args.english_sections).read_text(encoding="utf-8")) + compiled = compile_delivery(dossier, chinese_payload, english_payload) + files = ["Chinese-Full-Synastry-Report.md", "English-Full-Synastry-Report.md", "bilingual-delivery-receipt.json"] + if args.dry_run: + print(json.dumps({ + "status": "would-deliver", + "output_dir": str(output_dir), + "files": files, + "receipt_status": compiled["delivery_receipt"]["status"], + }, ensure_ascii=False)) + return 0 + _publish_delivery_bundle(output_dir, compiled) + print(json.dumps({ + "status": "delivered", + "output_dir": str(output_dir), + "files": files, + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py b/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py new file mode 100644 index 00000000..c0c98d60 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Prepare two private full-reading packets and a synastry dossier. + +This is the operational entry point for the full synastry Skill. It accepts +two private birth profiles, runs the native full-reading engine separately, +then builds the receipt and dossier consumed by the bilingual writing pass. +It deliberately does not write customer prose: that requires a chart reader +to distinguish the two people's evidence and live conditions. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime +from pathlib import Path +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[3] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +PRE_WORK_CHECK = ROOT / "scripts" / "pre_work_check.py" +DOSSIER_PATH = Path(__file__).with_name("build_full_synastry_dossier.py") +RECEIPT_PATH = Path(__file__).with_name("validate_full_synastry_packets.py") + + +def _load_module(name: str, path: Path) -> Any: + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_DOSSIER = _load_module("full_synastry_dossier", DOSSIER_PATH) +_RECEIPT = _load_module("full_synastry_receipt", RECEIPT_PATH) + + +def _private_output_directory(value: str) -> Path: + path = Path(value).expanduser().resolve() + try: + path.relative_to(ROOT) + except ValueError: + return path + raise ValueError("--output-dir must be outside the repository because it contains private birth data") + + +def _number(profile: Mapping[str, Any], key: str, *, minimum: float, maximum: float) -> float: + value = profile.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool) or not minimum <= float(value) <= maximum: + raise ValueError(f"{key} must be a number between {minimum} and {maximum}") + return float(value) + + +def normalize_profile(profile: Mapping[str, Any]) -> dict[str, Any]: + """Validate a private birth profile without retaining identity fields.""" + if not isinstance(profile, Mapping): + raise ValueError("birth profile must be a JSON object") + birth_date = profile.get("date") + birth_time = profile.get("time") + if not isinstance(birth_date, str): + raise ValueError("date is required as YYYY-MM-DD") + if not isinstance(birth_time, str): + raise ValueError("time is required as HH:MM or HH:MM:SS") + try: + day = date.fromisoformat(birth_date) + try: + parsed_time = datetime.strptime(birth_time, "%H:%M:%S").time() + except ValueError: + parsed_time = datetime.strptime(birth_time, "%H:%M").time() + except ValueError as exc: + raise ValueError("date/time must use YYYY-MM-DD and HH:MM or HH:MM:SS") from exc + place = profile.get("place") + if not isinstance(place, str) or not place.strip(): + raise ValueError("place is required; keep the full birth-place text in the private profile") + ayanamsa = str(profile.get("ayanamsa", "lahiri")).lower() + node_mode = str(profile.get("node_mode", "mean")).lower() + if node_mode not in {"mean", "true"}: + raise ValueError("node_mode must be mean or true") + return { + "year": day.year, "month": day.month, "day": day.day, + "hour": parsed_time.hour, "minute": parsed_time.minute, "second": parsed_time.second, + "lat": _number(profile, "latitude", minimum=-90, maximum=90), + "lon": _number(profile, "longitude", minimum=-180, maximum=180), + "tz": _number(profile, "timezone", minimum=-14, maximum=14), + "ayanamsa": ayanamsa, "node_mode": node_mode, + "gender": profile.get("gender") if profile.get("gender") in {"male", "female"} else None, + } + + +def full_reading_command(profile: Mapping[str, Any], *, today: str | None = None) -> list[str]: + """Build the native command from validated input; never hand-calculate charts.""" + command = [ + sys.executable, str(ENGINE), "full-reading", + "--year", str(profile["year"]), "--month", str(profile["month"]), "--day", str(profile["day"]), + "--hour", str(profile["hour"]), "--minute", str(profile["minute"]), "--second", str(profile["second"]), + "--lat", str(profile["lat"]), "--lon", str(profile["lon"]), "--tz", str(profile["tz"]), + "--ayanamsa", str(profile["ayanamsa"]), "--node-mode", str(profile["node_mode"]), + ] + if profile.get("gender"): + command.extend(("--gender", str(profile["gender"]))) + if today: + command.extend(("--today", today, "--transit-date", today)) + return command + + +def preflight_command() -> list[str]: + """Use the repository gate before native chart calculation.""" + return [ + sys.executable, str(PRE_WORK_CHECK), + "--remote-timeout", "8", "--command-timeout", "45", + ] + + +def _run_preflight() -> dict[str, Any]: + completed = subprocess.run(preflight_command(), check=False, capture_output=True, text=True) + if completed.returncode: + detail = completed.stderr.strip().splitlines()[-1:] or completed.stdout.strip().splitlines()[-1:] + raise RuntimeError("synastry preflight failed: " + (detail[0] if detail else "unknown failure")) + return { + "status": "passed", + "command": ["scripts/pre_work_check.py", "--remote-timeout", "8", "--command-timeout", "45"], + } + + +def _run_full_reading(profile: Mapping[str, Any], today: str | None) -> dict[str, Any]: + completed = subprocess.run(full_reading_command(profile, today=today), check=False, capture_output=True, text=True) + if completed.returncode: + message = completed.stderr.strip().splitlines()[-1:] or ["native full-reading failed"] + raise RuntimeError("full-reading failed: " + message[0]) + try: + result = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("full-reading did not return JSON") from exc + if not isinstance(result, dict): + raise RuntimeError("full-reading returned a non-object result") + return result + + +def prepare_synastry_packets(person_a: Mapping[str, Any], person_b: Mapping[str, Any], *, today: str | None = None) -> dict[str, Any]: + """Run both native charts concurrently and assemble their private evidence.""" + a, b = normalize_profile(person_a), normalize_profile(person_b) + if (a["ayanamsa"], a["node_mode"]) != (b["ayanamsa"], b["node_mode"]): + raise ValueError("both profiles must use the same ayanamsa and node_mode before direct comparison") + preflight = _run_preflight() + with ThreadPoolExecutor(max_workers=2) as pool: + future_a = pool.submit(_run_full_reading, a, today) + future_b = pool.submit(_run_full_reading, b, today) + packet_a, packet_b = future_a.result(), future_b.result() + return { + "preflight": preflight, + "person_a": packet_a, + "person_b": packet_b, + "execution_receipt": _RECEIPT.build_execution_receipt(packet_a, packet_b), + "dossier": _DOSSIER.build_full_synastry_dossier(packet_a, packet_b), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Prepare private full-synastry evidence from two birth profiles.") + parser.add_argument("--person-a", required=True, help="Private JSON profile: date, time, place, latitude, longitude, timezone, ayanamsa, node_mode") + parser.add_argument("--person-b", required=True, help="Private JSON profile: date, time, place, latitude, longitude, timezone, ayanamsa, node_mode") + parser.add_argument("--output-dir", required=True, help="Private directory outside the repository") + parser.add_argument("--today", help="Optional historical reference date YYYY-MM-DD; omit for the actual current date") + args = parser.parse_args() + if args.today: + date.fromisoformat(args.today) + output_dir = _private_output_directory(args.output_dir) + person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8")) + person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8")) + prepared = prepare_synastry_packets(person_a, person_b, today=args.today) + output_dir.mkdir(parents=True, exist_ok=True) + for name, value in prepared.items(): + (output_dir / f"{name}.json").write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({ + "status": "prepared", "output_dir": str(output_dir), + "dossier_status": prepared["dossier"].get("delivery_readiness"), + "next_step": "Write independent Chinese and English section payloads from dossier.json, then run compose_full_synastry_delivery.py.", + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py b/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py new file mode 100644 index 00000000..bb658ee8 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Validate independently written Chinese and English synastry reports. + +The validator checks shared evidence coverage against a private dossier. It +does not translate prose and never treats one language as the other's source. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any, Mapping + + +SCHEMA = "jyotish.full_synastry_bilingual_delivery_receipt.v1" +ZH_HEADINGS = ("结论总纲", "各自带入关系的需求", "情绪安全与相处感受", "吸引与亲密", "沟通、冲突与修复", "关系如何推进", "共同生活的收益与压力", "能不能稳定走下去", "关系应期") +EN_HEADINGS = ("Relationship Thesis", "What Each Person Brings", "Emotional Safety and Lived Experience", "Attraction and Closeness", "Communication, Conflict and Repair", "How the Relationship Progresses", "Shared Life: Gains and Pressures", "Can It Stay Steady?", "Relationship Weather") +EVIDENCE_LABELS = ( + "multi_system_consensus", "single_system_inference", "parameter_sensitive", + "unclosed_divisional_chart", "user_history_verification_required", "blocked", +) +SUPPLEMENTARY_METHOD_TERMS = ("Shukuyo", "Western synastry", "Composite chart", "Davison", "Prashna", "宿曜", "星宿关系", "西占合盘", "组合盘", "问卦") +PRIVATE_EVIDENCE_TERMS = ( + "execution_receipt", "source_map", "delivery_readiness", "module_inventory", "modules.", + "pair_interaction_evidence", "mutual_affection_activation", "reader_section_brief", + "recipient_experience_evidence", "d1_recipient_evidence", "d9_recipient_evidence", + "parallel_personal_timing", "event_resonance_gate", + "原始模块索引", "执行回执", "证据包路径", "章节证据简报", "接收方证据", +) +ZH_READER_MIN_CHARACTERS = 2000 +EN_READER_MIN_WORDS = 1200 +READER_BANNED_TERMS = ("灵魂伴侣", "宇宙安排", "百分百", "一定会", "soulmate", "the universe", "guaranteed", "100%") +ZH_D9_TERMS = ("D9", "九分盘", "婚姻分盘", "Navamsa", "Navamsha") +EN_D9_TERMS = ("D9", "Navamsa", "Navamsha") +ZH_ANALYST_LED_OPENERS = ("这段关系的核心", "这段关系不是", "不是有没有吸引", "不是“", "不是\"") +EN_ANALYST_LED_OPENERS = ("the core of this relationship", "this relationship is not") +ZH_AFFECTION_OPENING_TERMS = ("珍惜", "靠近", "在意", "被理解", "被重视", "安心", "温暖", "好感", "心动", "亲近", "回应", "投入") +EN_AFFECTION_OPENING_TERMS = ("cherish", "close", "care", "understood", "valued", "safe", "warm", "drawn", "affection", "respond", "invest") +ZH_SECTION_MIN_CHARACTERS = 100 +EN_SECTION_MIN_WORDS = 70 +ZH_AFFECTION_SECTION_WARMTH_TERMS = ("珍惜", "靠近", "在意", "被理解", "被重视", "安心", "温暖", "好感", "心动", "亲近") +ZH_AFFECTION_SECTION_BOUNDARY_TERMS = ("空间", "压力", "边界", "催促", "忽略", "退缩") +EN_AFFECTION_SECTION_WARMTH_TERMS = ("cherish", "close", "care", "understood", "valued", "safe", "warm", "drawn", "affection") +EN_AFFECTION_SECTION_BOUNDARY_TERMS = ("space", "pressure", "boundary", "rush", "ignored", "withdraw") + + +def _heading_status(text: str, headings: tuple[str, ...]) -> dict[str, bool]: + return {heading: heading in text for heading in headings} + + +def _opening_after_heading(text: str, heading: str, headings: tuple[str, ...]) -> str: + """Return the reader-visible opening of one section, not the whole report.""" + return _section_bodies(text, headings).get(heading, "")[:500] + + +def _section_bodies(text: str, headings: tuple[str, ...]) -> dict[str, str]: + """Extract heading bodies without mistaking prose mentions for headings.""" + found: list[tuple[int, int, str]] = [] + for heading in headings: + pattern = re.compile( + rf"^[ \t]*(?:#{{1,6}}[ \t]*)?(?:(?:[一二三四五六七八九十]+|[0-9]+|[IVXLCDM]+)[、..)][ \t]*)?{re.escape(heading)}[ \t#]*$", + re.MULTILINE, + ) + match = pattern.search(text) + if match: + found.append((match.start(), match.end(), heading)) + found.sort() + bodies = {heading: "" for heading in headings} + for index, (_start, end, heading) in enumerate(found): + next_start = found[index + 1][0] if index + 1 < len(found) else len(text) + bodies[heading] = text[end:next_start].strip() + return bodies + + +def _opening_quality(chinese: str, english: str) -> dict[str, Any]: + zh_opening = _opening_after_heading(chinese, "结论总纲", ZH_HEADINGS) + en_opening = _opening_after_heading(english, "Relationship Thesis", EN_HEADINGS) + zh_hits = [phrase for phrase in ZH_ANALYST_LED_OPENERS if phrase in zh_opening] + en_lower = en_opening.lower() + en_hits = [phrase for phrase in EN_ANALYST_LED_OPENERS if phrase in en_lower] + return { + "chinese_analyst_led_openers": zh_hits, + "english_analyst_led_openers": en_hits, + "status": "passed" if not zh_hits and not en_hits else "review_required", + "boundary": "The thesis must begin with the reader's lived relationship question and answer, not an analyst-led contrast such as 'not X but Y'.", + } + + +def _dossier_status(dossier: Mapping[str, Any]) -> dict[str, str]: + rows = dossier.get("source_map", []) if isinstance(dossier.get("source_map"), list) else [] + return { + str(row.get("label")): str(row.get("status")) + for row in rows if isinstance(row, Mapping) and row.get("label") + } + + +def _d9_pair_evidence_status(dossier: Mapping[str, Any]) -> str: + interaction = dossier.get("pair_interaction_evidence") + if not isinstance(interaction, Mapping): + return "not_available" + d9 = interaction.get("d9_interaction_evidence") + return str(d9.get("status")) if isinstance(d9, Mapping) else "not_available" + + +def _mutual_affection_status(dossier: Mapping[str, Any]) -> str: + brief = dossier.get("mutual_affection_activation") + return str(brief.get("status")) if isinstance(brief, Mapping) else "not_available" + + +def _opening_affection_coverage( + dossier: Mapping[str, Any], chinese: str, english: str, +) -> dict[str, Any]: + """Require a warm, evidence-backed opening only when the brief exists.""" + status = _mutual_affection_status(dossier) + required = status == "executed" + zh_opening = _opening_after_heading(chinese, "结论总纲", ZH_HEADINGS) + en_opening = _opening_after_heading(english, "Relationship Thesis", EN_HEADINGS).lower() + chinese_terms = [term for term in ZH_AFFECTION_OPENING_TERMS if term in zh_opening] + english_terms = [term for term in EN_AFFECTION_OPENING_TERMS if term in en_opening] + return { + "available_status": status, + "required_in_opening": required, + "chinese_terms": chinese_terms, + "english_terms": english_terms, + "status": "passed" if not required or (chinese_terms and english_terms) else "review_required", + "boundary": ( + "A warm opening describes evidence-backed conditions for reciprocal care. " + "It must not promise love, consent, fidelity, or an outcome." + ), + } + + +def _section_depth_coverage(chinese: str, english: str) -> dict[str, Any]: + chinese_bodies = _section_bodies(chinese, ZH_HEADINGS) + english_bodies = _section_bodies(english, EN_HEADINGS) + chinese_rows = { + heading: len(re.findall(r"[\u4e00-\u9fff]", body)) + for heading, body in chinese_bodies.items() + } + english_rows = { + heading: len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", body)) + for heading, body in english_bodies.items() + } + return { + "chinese_characters": chinese_rows, + "chinese_minimum_per_section": ZH_SECTION_MIN_CHARACTERS, + "english_words": english_rows, + "english_minimum_per_section": EN_SECTION_MIN_WORDS, + "status": "passed" if ( + all(count >= ZH_SECTION_MIN_CHARACTERS for count in chinese_rows.values()) + and all(count >= EN_SECTION_MIN_WORDS for count in english_rows.values()) + ) else "review_required", + "boundary": "This guards against heading-only sections and does not certify astrological correctness or narrative quality.", + } + + +def _attraction_reciprocity_coverage(dossier: Mapping[str, Any], chinese: str, english: str) -> dict[str, Any]: + """Require warmth and boundary coverage when reciprocal evidence exists.""" + required = _mutual_affection_status(dossier) == "executed" + chinese_section = _section_bodies(chinese, ZH_HEADINGS)["吸引与亲密"] + english_section = _section_bodies(english, EN_HEADINGS)["Attraction and Closeness"].lower() + chinese_warmth = [term for term in ZH_AFFECTION_SECTION_WARMTH_TERMS if term in chinese_section] + chinese_boundary = [term for term in ZH_AFFECTION_SECTION_BOUNDARY_TERMS if term in chinese_section] + english_warmth = [term for term in EN_AFFECTION_SECTION_WARMTH_TERMS if term in english_section] + english_boundary = [term for term in EN_AFFECTION_SECTION_BOUNDARY_TERMS if term in english_section] + passed = not required or ( + chinese_warmth and chinese_boundary and english_warmth and english_boundary + ) + return { + "required": required, + "chinese_warmth_terms": chinese_warmth, + "chinese_boundary_terms": chinese_boundary, + "english_warmth_terms": english_warmth, + "english_boundary_terms": english_boundary, + "status": "passed" if passed else "review_required", + "boundary": ( + "This requires reciprocal-care and space/pressure language when evidence is executed. " + "It does not prove consent, attraction, fidelity or any relationship outcome." + ), + } + + +def build_delivery_receipt(dossier: Mapping[str, Any], chinese: str, english: str) -> dict[str, Any]: + source_status = _dossier_status(dossier) + cited_labels = {label: {"zh": label in chinese, "en": label in english} for label in EVIDENCE_LABELS} + primary_pair_tasks = [ + row for row in (dossier.get("execution_receipt", {}) or {}).get("pair_execution_tasks", []) + if isinstance(row, Mapping) and row.get("status") in {"ready_for_comparison", "parameter_sensitive", "blocked"} + ] + english_cjk_ratio = len(re.findall(r"[\u4e00-\u9fff]", english)) / max(len(english), 1) + chinese_characters = len(re.findall(r"[\u4e00-\u9fff]", chinese)) + english_words = len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", english)) + zh_headings = _heading_status(chinese, ZH_HEADINGS) + en_headings = _heading_status(english, EN_HEADINGS) + d9_status = _d9_pair_evidence_status(dossier) + d9_required = d9_status == "executed" + d9_coverage = { + "available_status": d9_status, + "required_in_narrative": d9_required, + "chinese": any(term in chinese for term in ZH_D9_TERMS), + "english": any(term.lower() in english.lower() for term in EN_D9_TERMS), + } + opening_quality = _opening_quality(chinese, english) + opening_affection = _opening_affection_coverage(dossier, chinese, english) + section_depth = _section_depth_coverage(chinese, english) + attraction_reciprocity = _attraction_reciprocity_coverage(dossier, chinese, english) + return { + "schema": SCHEMA, + "dossier_schema": dossier.get("schema"), + "composition": { + "chinese": "direct_from_structured_dossier", + "english": "direct_from_structured_dossier_not_translated_from_chinese", + }, + "headings": {"chinese": zh_headings, "english": en_headings}, + "evidence_labels": cited_labels, + "source_map_status": source_status, + "pair_tasks": [{"task": row.get("task"), "status": row.get("status")} for row in primary_pair_tasks], + "d9_narrative_coverage": d9_coverage, + "opening_quality": opening_quality, + "opening_affection_coverage": opening_affection, + "section_depth_coverage": section_depth, + "attraction_reciprocity_coverage": attraction_reciprocity, + "supplementary_method_terms": { + "chinese": {term: term in chinese for term in SUPPLEMENTARY_METHOD_TERMS}, + "english": {term: term in english for term in SUPPLEMENTARY_METHOD_TERMS}, + }, + "private_evidence_terms": { + "chinese": {term: term in chinese for term in PRIVATE_EVIDENCE_TERMS}, + "english": {term: term in english for term in PRIVATE_EVIDENCE_TERMS}, + }, + "english_cjk_ratio": round(english_cjk_ratio, 5), + "reader_depth": { + "chinese_characters": chinese_characters, + "chinese_minimum": ZH_READER_MIN_CHARACTERS, + "english_words": english_words, + "english_minimum": EN_READER_MIN_WORDS, + "status": "passed" if chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS else "review_required", + }, + "reader_language_terms": { + "chinese": {term: term in chinese for term in READER_BANNED_TERMS}, + "english": {term: term in english.lower() for term in READER_BANNED_TERMS}, + }, + "status": "passed" if ( + dossier.get("delivery_readiness") == "ready_for_narrative" + and all(zh_headings.values()) + and all(en_headings.values()) + and opening_quality["status"] == "passed" + and opening_affection["status"] == "passed" + and section_depth["status"] == "passed" + and attraction_reciprocity["status"] == "passed" + and (not d9_required or (d9_coverage["chinese"] and d9_coverage["english"])) + and not any(term in chinese for term in SUPPLEMENTARY_METHOD_TERMS) + and not any(term in english for term in SUPPLEMENTARY_METHOD_TERMS) + and not any(term in chinese for term in PRIVATE_EVIDENCE_TERMS) + and not any(term in english for term in PRIVATE_EVIDENCE_TERMS) + and chinese_characters >= ZH_READER_MIN_CHARACTERS + and english_words >= EN_READER_MIN_WORDS + and not any(term in chinese for term in READER_BANNED_TERMS) + and not any(term in english.lower() for term in READER_BANNED_TERMS) + and english_cjk_ratio < 0.05 + ) else "review_required", + "boundary": "This validates delivery structure and shared evidence coverage. It does not prove a translation is absent or replace technical review.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate independent Chinese and English full synastry reports.") + parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON") + parser.add_argument("--chinese", required=True, help="Chinese Markdown report") + parser.add_argument("--english", required=True, help="English Markdown report written directly from the dossier") + parser.add_argument("--output", help="Private receipt output path") + args = parser.parse_args() + dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8")) + chinese = Path(args.chinese).read_text(encoding="utf-8") + english = Path(args.english).read_text(encoding="utf-8") + receipt = build_delivery_receipt(dossier, chinese, english) + encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + if args.output: + Path(args.output).write_text(encoded + "\n", encoding="utf-8") + else: + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py b/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py new file mode 100644 index 00000000..c5b5b219 --- /dev/null +++ b/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Create a privacy-safe execution receipt for two full-reading packets. + +This is deliberately an audit adapter, not an interpreter. It establishes +whether both people were calculated under comparable settings and whether the +relationship route's individual evidence modules are actually present before a +synastry narrative is written. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Mapping + + +SCHEMA = "jyotish.full_synastry_execution_receipt.v1" + +# The paths are the native full-reading outputs. Several technique labels +# intentionally share a producer because one calculation supports more than +# one relationship question; the report writer must still account for each. +REQUIRED_TECHNIQUES: tuple[tuple[str, tuple[str, ...], str], ...] = ( + ("D1/Rashi", ("chart",), "natal promise and house framework"), + ("Functional Benefic/Malefic", ("functional_benefic_malefic",), "Lagna-specific roles"), + ("Bhava Chalit", ("bhava_chalit",), "house-boundary confirmation"), + ("Planetary Aspects", ("aspects",), "actual contact and friction channels"), + ("Planetary Friendship", ("planetary_friendship",), "planetary cooperation context"), + ("D9/Navamsa", ("varga_full.D9_Navamsa", "d9_navamsa_expanded"), "long-term relationship structure"), + ("Vimshottari", ("dasha",), "primary planet-period timing"), + ("Dasha Sandhi", ("dasha_sandhi",), "period-transition pressure"), + ("Narayana Dasha", ("narayana_dasha",), "rashi-period cross-check"), + ("Jaimini/Chara", ("jaimini",), "karakas, arudhas and rashi timing"), + ("Darakaraka (DK)", ("jaimini",), "partner significator"), + ("Upapada Lagna (UL)", ("special_lagnas", "yogas_doshas"), "marriage formation and sustenance"), + ("Argala", ("argala",), "support and obstruction"), + ("Shadbala", ("shadbala",), "relative planetary strength"), + ("Ashtakavarga", ("ashtakavarga",), "transit capacity"), + ("Vimsopaka", ("vimsopaka",), "cross-varga dignity weighting"), + ("Vargottama", ("vargottama",), "D1-D9 reinforcement"), + ("Pushkara", ("pushkara",), "supportive Navamsa condition"), + ("Dispositor Chain", ("dispositor_chains", "final_dispositors"), "energy-flow resolution"), + ("Inter-chart Linkage", ("inter_chart_linkage",), "D1-D9 linkage within each chart"), + ("Avasthas", ("avasthas",), "condition and capacity of relationship significators"), + ("Bhava Bala", ("bhava_bala",), "relative capacity of relationship and shared-life houses"), + ("Sensitive Points", ("sensitive_points",), "derived point context; never a standalone verdict"), + ("Lagna Vargottamamsa", ("lagna_vargottamamsa",), "D1-D9 ascendant reinforcement"), + ("Extended Vargas", ("varga_extended",), "D5/D6/D8/D11 context; use only by documented topic"), + ("Tajika Yogas", ("tajika_yogas",), "annual activation context"), + ("Vivah and other Sahams", ("sahams",), "event-sensitive points"), + ("Mangal Dosha", ("yogas_doshas",), "Mars-only marriage-pressure evidence"), + ("Punarphoo", ("punarphoo",), "observation-only Moon-Saturn layer"), + ("Arudha Lagna", ("special_lagnas", "yogas_doshas"), "public relationship frame"), + ("Tithi Lord", ("tithi_lord",), "lunar relationship context"), + ("Rashi Tulya Navamsa", ("rashi_tulya_navamsa",), "D1-D9 comparison"), + ("Marriage Counting", ("marriage_counting",), "supplemental relationship-pattern layer"), + ("D30 Trimshamsa", ("trimshamsa_d30",), "crisis-pressure context only"), + ("Muntha / Solar Return", ("muntha", "solar_return"), "annual relationship weather"), + ("Bhrigu Pada Dasha", ("bhrigu_pada_dasha",), "parameter-sensitive comparison layer"), + ("Nakshatra Advanced", ("nakshatra_dasha", "nakshatra_advanced"), "nakshatra timing/context"), + ("Double Transit / LL7L", ("double_transit_pac", "transit_ll7l"), "reference-date activation"), + ("Transit Multi-reference", ("transit_positions", "transit_multi_reference"), "cross-reference present activation"), + ("KP 7th-house layer", ("kp",), "formalisation question support"), + ("Sudarshana Chakra", ("sudarshana",), "triple-lagna confirmation"), + ("Yogini Dasha", ("yogini_dasha",), "secondary timing only; retain its own birth-balance boundary"), + ("Ashtottari Dasha", ("ashtottari_dasha",), "conditional secondary timing; verify applicability"), + ("Kalachakra Dasha", ("kalachakra_dasha",), "conditional secondary timing; verify method profile"), + ("Navamsha Dasha", ("navamsha_dasha",), "D9-oriented secondary timing; parameter-sensitive"), + ("Niryaana Shoola Dasha", ("niryaana_shoola_dasha",), "specialized secondary timing; do not lead conclusions"), + ("Shastihayani Dasha", ("shastihayani_dasha",), "conditional secondary timing; applicability required"), + ("Dasha Convergence", ("dasa_convergence",), "cross-dasha agreement within each chart"), + ("Relationship Strict Evidence", ("relationship_strict_evidence",), "relationship-route rule and book-evidence payload"), + ("Western comparison layer", ("unified_western_reading",), "independent supporting lens"), +) + +PAIR_TASKS: tuple[tuple[str, tuple[str, ...], str], ...] = ( + ( + "D1/D9 mutual overlays and relationship axes", + ("chart", "varga_full.D9_Navamsa"), + "primary Jyotish pair structure", + ), + ( + "lived interaction: emotional safety, attraction, communication and conflict pattern", + ("chart", "aspects", "planetary_friendship", "varga_full.D9_Navamsa"), + "compare mutual Lagna/Moon/Venus/Mars/Mercury contacts and D9 confirmation; write observable interaction, not personality labels", + ), + ( + "shared gain and cost: resources, home, work and private-space axes", + ("chart", "varga_full.D2_Hora", "varga_full.D4_Chaturthamsa", "varga_full.D10_Dasamsa", "bhava_bala", "ashtakavarga"), + "compare 2/4/6/10/11/12-house capacity and relevant varga context; no financial prediction from compatibility alone", + ), + ( + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ("chart", "jaimini", "special_lagnas", "argala", "yogas_doshas", "trimshamsa_d30"), + "separate attraction, commitment, deep binding, recovery and pressure; D30 is bounded context only", + ), + ( + "DK, UL, UL-second and A7/Darapada cross-comparison", + ("jaimini", "special_lagnas"), + "primary Jaimini marriage structure", + ), + ( + "two-person Dasha/Narayana/Chara context comparison", + ("dasha", "narayana_dasha", "jaimini"), + "primary timing convergence", + ), + ( + "secondary-dasha convergence and disagreement review", + ("nakshatra_dasha", "yogini_dasha", "ashtottari_dasha", "kalachakra_dasha", "navamsha_dasha", "dasa_convergence"), + "secondary systems qualify or challenge a primary timing window; never invent a date when profiles conflict", + ), + ( + "shared transit, Tajika, Saham and annual-window comparison", + ("tajika_yogas", "sahams", "muntha"), + "annual and transit corroboration", + ), + ( + "international Ashtakoota core, additional Kutas, Mars-only Kuja and separate Papasamya", + ("chart",), + "limited Moon/nakshatra and pressure context; build through marriage-match", + ), + ( + "Nakshatra relationship context", + ("nakshatra_dasha",), + "Jyotish nakshatra context; never a standalone marriage verdict", + ), + ( + "optional 27-nakshatra Shukuyo narrative layer", + (), + "separate cultural narrative only; never merge into Jyotish evidence", + ), + ( + "optional Western synastry/composite/Davison layer", + ("unified_western_reading",), + "separate supporting perspective; a natal Western packet is not a composite chart", + ), + ( + "Prashna context when a real question chart was supplied", + (), + "guarded context only; never replace the two natal charts", + ), +) + + +def _packet(value: Mapping[str, Any]) -> Mapping[str, Any]: + nested = value.get("raw_full_reading") if isinstance(value, Mapping) else None + return nested if isinstance(nested, Mapping) else value + + +def _get_path(value: Mapping[str, Any], dotted: str) -> Any: + cursor: Any = value + for key in dotted.split("."): + if not isinstance(cursor, Mapping) or key not in cursor: + return None + cursor = cursor[key] + return cursor + + +def _status(value: Any) -> str: + if value is None: + return "blocked" + if isinstance(value, Mapping): + state = str(value.get("execution_status") or value.get("status", "")).lower() + if state in {"blocked", "not_available", "unavailable", "skipped"}: + return "blocked" + if state in { + "partial", "partial_verified", "parameter_sensitive", "observation_only", + "guarded", "reference_only", "computed_pending_validation", + }: + return "parameter_sensitive" + if value.get("skipped") is True or value.get("available") is False: + return "blocked" + return "executed" + + +def _profile(packet: Mapping[str, Any]) -> dict[str, Any]: + birth = packet.get("birth_info", {}) if isinstance(packet.get("birth_info"), Mapping) else {} + profile = packet.get("calculation_profile", {}) if isinstance(packet.get("calculation_profile"), Mapping) else {} + settings = profile.get("effective_settings", {}) if isinstance(profile.get("effective_settings"), Mapping) else {} + return { + "ayanamsa": birth.get("ayanamsa_name") or birth.get("ayanamsa") or profile.get("ayanamsa") or settings.get("ayanamsa"), + "node_mode": birth.get("node_mode") or profile.get("node_mode") or settings.get("node_mode"), + "position_mode": profile.get("position_mode") or settings.get("position_mode"), + "house_system": profile.get("house_system") or settings.get("house_system"), + } + + +def _technique_row(packet: Mapping[str, Any], definition: tuple[str, tuple[str, ...], str]) -> dict[str, Any]: + label, candidates, purpose = definition + modules = packet.get("modules", {}) if isinstance(packet.get("modules"), Mapping) else {} + found_path = next((path for path in candidates if _get_path(modules, path) is not None), None) + value = _get_path(modules, found_path) if found_path else None + return { + "technique": label, + "status": _status(value), + "source_path": f"modules.{found_path}" if found_path else None, + "purpose": purpose, + } + + +def _pair_task_row( + person_a: Mapping[str, Any], + person_b: Mapping[str, Any], + definition: tuple[str, tuple[str, ...], str], +) -> dict[str, Any]: + """State whether both packets can support a comparison, not its conclusion.""" + task, required_paths, boundary = definition + if not required_paths: + return { + "task": task, + "status": "not_applicable", + "required_inputs": [], + "boundary": boundary, + } + modules_a = person_a.get("modules", {}) if isinstance(person_a.get("modules"), Mapping) else {} + modules_b = person_b.get("modules", {}) if isinstance(person_b.get("modules"), Mapping) else {} + inputs = [] + for path in required_paths: + a_value = _get_path(modules_a, path) + b_value = _get_path(modules_b, path) + a_status, b_status = _status(a_value), _status(b_value) + if a_status == b_status == "executed": + status = "ready_for_comparison" + elif "blocked" in {a_status, b_status}: + status = "blocked" + else: + status = "parameter_sensitive" + inputs.append({ + "source_path": f"modules.{path}", + "status": status, + "person_a_status": a_status, + "person_b_status": b_status, + }) + overall = "ready_for_comparison" + if any(row["status"] == "blocked" for row in inputs): + overall = "blocked" + elif any(row["status"] == "parameter_sensitive" for row in inputs): + overall = "parameter_sensitive" + return { + "task": task, + "status": overall, + "required_inputs": inputs, + "boundary": boundary, + } + + +def build_execution_receipt(person_a: Mapping[str, Any], person_b: Mapping[str, Any]) -> dict[str, Any]: + """Return a non-identifying audit receipt for a pair of full readings.""" + a, b = _packet(person_a), _packet(person_b) + profile_a, profile_b = _profile(a), _profile(b) + profile_complete = all(profile_a.values()) and all(profile_b.values()) + profile_match = profile_complete and all(profile_a[key] == profile_b[key] for key in profile_a) + rows = [] + for definition in REQUIRED_TECHNIQUES: + a_row, b_row = _technique_row(a, definition), _technique_row(b, definition) + combined = "executed" if a_row["status"] == b_row["status"] == "executed" else "blocked" + if combined == "blocked" and {a_row["status"], b_row["status"]} <= {"executed", "partial", "parameter_sensitive", "observation_only", "guarded"}: + combined = "parameter_sensitive" + rows.append({ + "technique": definition[0], + "status": combined, + "person_a": a_row, + "person_b": b_row, + }) + return { + "schema": SCHEMA, + "calculation_profile": {"person_a": profile_a, "person_b": profile_b, "compatible": profile_match}, + "profile_gate": "passed" if profile_match else "blocked", + "profile_gate_reason": None if profile_match else ( + "calculation_profile_incomplete" if not profile_complete else "calculation_profile_mismatch" + ), + "individual_technique_audit": rows, + "pair_execution_tasks": [_pair_task_row(a, b, task) for task in PAIR_TASKS], + "boundary": ( + "This receipt proves packet presence and calculation-profile comparability only. " + "It does not turn a module into a conclusion; pair tasks, book evidence, MEVG and case calibration remain required." + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate two full-reading packets before a full synastry interpretation.") + parser.add_argument("--person-a", required=True, help="JSON full-reading or export packet for person A") + parser.add_argument("--person-b", required=True, help="JSON full-reading or export packet for person B") + parser.add_argument("--output", help="Optional receipt path; do not place customer data in a repository path.") + args = parser.parse_args() + with Path(args.person_a).open(encoding="utf-8") as handle: + person_a = json.load(handle) + with Path(args.person_b).open(encoding="utf-8") as handle: + person_b = json.load(handle) + receipt = build_execution_receipt(person_a, person_b) + encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + if args.output: + Path(args.output).write_text(encoded + "\n", encoding="utf-8") + else: + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_all.py b/tests/run_all.py index a7296594..68930f97 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -35,8 +35,8 @@ def t5(): from kakshya import calc_kakshya_scores, get_kakshya_lord @test("kp_system import") def t6(): from kp_system import get_kp_lords, calc_kp_analysis -@test("synastry import") -def t7(): from synastry import calc_ashtakoot +@test("ashtakoot import") +def t7(): from ashtakoot import calculate_ashtakoot @test("prashna import") def t8(): from prashna import calc_prashna_chart, get_kp_prashna_answer @@ -111,8 +111,8 @@ def t23(): @test("Synastry score range") def t24(): - from synastry import calc_ashtakoot - r = calc_ashtakoot(15.5, 120) + from ashtakoot import calculate_ashtakoot + r = calculate_ashtakoot(15.5, 120) assert 0 <= r['total_score'] <= 36 @test("Sade Sati detection") @@ -507,14 +507,14 @@ def t68(): # ── Synastry tests ── @test("Synastry score in 0-36 range") def t69(): - from synastry import calc_ashtakoot - r = calc_ashtakoot(15.5, 120.0) + from ashtakoot import calculate_ashtakoot + r = calculate_ashtakoot(15.5, 120.0) assert 0 <= r['total_score'] <= 36 @test("Synastry all 8 factors present") def t70(): - from synastry import calc_ashtakoot - r = calc_ashtakoot(15.5, 75.0) + from ashtakoot import calculate_ashtakoot + r = calculate_ashtakoot(15.5, 75.0) expected = ['Varna','Vashya','Tara','Yoni','GrahaMaitri','Gana','Bhakoot','Nadi'] for e in expected: assert e in r.get('scores', {}), f"Missing {e}" diff --git a/tests/test_marriage_match_api.py b/tests/test_marriage_match_api.py new file mode 100644 index 00000000..c05d5175 --- /dev/null +++ b/tests/test_marriage_match_api.py @@ -0,0 +1,134 @@ +"""Marriage-match API glue: order, cache, and route registration.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from marriage_match import build_marriage_match_dossier +from marriage_match_api import ( + CACHE_NAME, + MarriageMatchInputError, + compute_marriage_match, + compute_synastry_full_dossier, +) +import marriage_match_api as api +from synastry_full_dossier import RULES_VERSION +import api_heavy_compute_gate + + +def chart(asc, moon, venus, mars): + return { + "ascendant": {"longitude": asc}, + "planets": { + "Moon": {"longitude": moon}, + "Venus": {"longitude": venus}, + "Mars": {"longitude": mars}, + "Saturn": {"longitude": 210}, + "Sun": {"longitude": 120}, + "Rahu": {"longitude": 300}, + "Ketu": {"longitude": 120}, + }, + } + + +def _pressure_signature(report): + people = [] + for key in ("person_a", "person_b"): + rows = report["papasamya"][key]["active_placements"] + people.append(tuple(sorted( + (row["planet"], row["reference"], row["house"]) for row in rows + ))) + return tuple(sorted(people)) + + +def test_swapped_people_keep_pairing_kuja_and_pressure(): + left = chart(10, 40, 70, 190) + right = chart(100, 130, 160, 250) + forward = build_marriage_match_dossier(left, right) + reverse = build_marriage_match_dossier(right, left) + assert forward["ashtakoota_36"]["international_core"]["scores"] == reverse["ashtakoota_36"]["international_core"]["scores"] + assert forward["ashtakoota_36"]["international_core"]["total_score"] == reverse["ashtakoota_36"]["international_core"]["total_score"] + assert forward["kuja_mangal_dosha"]["outcome"] == reverse["kuja_mangal_dosha"]["outcome"] + assert forward["kuja_mangal_dosha"]["person_a"]["active_reference_count"] == reverse["kuja_mangal_dosha"]["person_b"]["active_reference_count"] + assert _pressure_signature(forward) == _pressure_signature(reverse) + + +def test_snapshot_endpoint_rejects_a_non_object(): + with pytest.raises(MarriageMatchInputError): + compute_marriage_match({"person_a": []}) + + +def test_disk_cache_reuses_one_build(monkeypatch, tmp_path): + monkeypatch.setenv("GITHUB_SHA", "marriage-match-cache-test") + def cache_dir(name: str = "consult_full_data_cache"): + path = tmp_path / name + path.mkdir(parents=True, exist_ok=True) + return path + + monkeypatch.setattr(api._cache, "cache_dir", cache_dir) + calls = {"n": 0} + + def fake(_handler, _person_a, _person_b): + calls["n"] += 1 + return {"rules_version": RULES_VERSION, "dossier": {"schema": "fixture"}, "raman_marriage_match": {}} + + monkeypatch.setattr( + "marriage_match_api.build_synastry_full_dossier", + fake, + ) + body = { + "person_a": {"year": 1990, "month": 1, "day": 2, "hour": 3, "minute": 4, "lat": 1, "lon": 2, "tz": 8}, + "person_b": {"year": 1991, "month": 5, "day": 6, "hour": 7, "minute": 8, "lat": 3, "lon": 4, "tz": 8}, + } + first = compute_synastry_full_dossier(object(), body) + second = compute_synastry_full_dossier(object(), body) + assert first == second + assert calls["n"] == 1 + assert list((tmp_path / CACHE_NAME).glob("*.json")) + + +def test_missing_commit_does_not_reuse_the_disk_cache(monkeypatch): + monkeypatch.delenv("GITHUB_SHA", raising=False) + calls = {"n": 0} + + def fake(_handler, _person_a, _person_b): + calls["n"] += 1 + return {"rules_version": RULES_VERSION, "dossier": {}, "raman_marriage_match": {}} + + monkeypatch.setattr("marriage_match_api.build_synastry_full_dossier", fake) + body = { + "person_a": {"year": 1990, "month": 1, "day": 2, "hour": 3, "minute": 4, "lat": 1, "lon": 2, "tz": 8}, + "person_b": {"year": 1991, "month": 5, "day": 6, "hour": 7, "minute": 8, "lat": 3, "lon": 4, "tz": 8}, + } + compute_synastry_full_dossier(object(), body) + compute_synastry_full_dossier(object(), body) + assert calls["n"] == 2 + + +def test_routes_are_registered_and_the_dossier_is_heavy(): + source = (ROOT / "scripts" / "jyotish_api_server.py").read_text(encoding="utf-8") + assert "elif path == '/api/marriage-match':" in source + assert "elif path == '/api/synastry_full_dossier':" in source + assert "/api/synastry_full_dossier" in api_heavy_compute_gate.HEAVY_COMPUTE_PATHS + assert "authorizeUsage" not in (ROOT / "frontend" / "src" / "lib" / "marriage-match-service.ts").read_text(encoding="utf-8") + assert not (ROOT / "scripts" / "synastry.py").exists() + + +def test_snapshot_golden_matches_the_engine(): + fixture_path = ROOT / "frontend" / "tests" / "fixtures" / "marriage-match-snapshot.json" + payload = {"person_a": chart(12, 48, 96, 200), "person_b": chart(140, 210, 30, 300)} + result = compute_marriage_match(payload) + if os.environ.get("WRITE_MARRIAGE_MATCH_GOLDEN") == "1": + fixture_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + stored = json.loads(fixture_path.read_text(encoding="utf-8")) + assert result == stored diff --git a/tests/test_marriage_match_product.py b/tests/test_marriage_match_product.py new file mode 100644 index 00000000..fb6d2df3 --- /dev/null +++ b/tests/test_marriage_match_product.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import os +import sys + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from marriage_match import build_marriage_match_dossier + + +def chart(asc, moon, venus, mars, *, saturn=210, sun=120, rahu=300, ketu=120): + return { + "ascendant": {"longitude": asc}, + "planets": { + "Moon": {"longitude": moon}, "Venus": {"longitude": venus}, + "Mars": {"longitude": mars}, "Saturn": {"longitude": saturn}, + "Sun": {"longitude": sun}, "Rahu": {"longitude": rahu}, "Ketu": {"longitude": ketu}, + }, + } + + +def test_default_profile_separates_mars_kuja_from_papasamya(): + a = chart(0, 30, 60, 180, saturn=180) + b = chart(90, 120, 150, 270, saturn=270) + report = build_marriage_match_dossier(a, b) + assert report["rule_profile"]["id"] == "practitioner_consensus_v1" + assert report["kuja_mangal_dosha"]["person_a"]["scope"] == "Mars only" + pressure = report["papasamya"]["person_a"] + assert {row["planet"] for row in pressure["planet_rows"]} == {"Mars", "Saturn", "Sun", "Rahu", "Ketu"} + assert "score" not in report["kuja_mangal_dosha"] + + +def test_kuja_comparison_is_gender_neutral_and_not_numeric_tolerance(): + a = chart(0, 30, 60, 180) + b = chart(90, 120, 150, 270) + forward = build_marriage_match_dossier(a, b)["kuja_mangal_dosha"] + reverse = build_marriage_match_dossier(b, a)["kuja_mangal_dosha"] + assert forward["gender_asymmetry_used"] is False + assert forward["outcome"] == reverse["outcome"] + assert forward["comparison_method"] == "neutral_reference_count" + + +def test_raman_variant_is_visible_and_first_house_difference_is_explicit(): + # Mars in the first house from each reference frame: default sees it; + # Raman's selected lineage does not. + same_sign = chart(0, 0, 0, 0) + default = build_marriage_match_dossier(same_sign, same_sign) + raman = build_marriage_match_dossier(same_sign, same_sign, profile_id="raman_muhurtha_v1") + assert default["kuja_mangal_dosha"]["person_a"]["active_reference_count"] == 3 + assert raman["kuja_mangal_dosha"]["person_a"]["active_reference_count"] == 0 + assert raman["rule_profile"]["id"] == "raman_muhurtha_v1" + + +def test_bhakoot_and_nadi_exceptions_are_exposed_without_rewriting_raw_score(): + # Moon longitudes both in Ashwini but different padas: same Nadi raw zero, + # candidate cancellation stays a separately auditable flag. + a = chart(0, 1, 20, 180) + b = chart(90, 4, 120, 270) + report = build_marriage_match_dossier(a, b) + nadi = report["ashtakoota_36"]["exception_review"]["nadi"] + assert nadi["raw_score"] == 0 + assert "same_nakshatra_different_pada" in nadi["candidate_cancellations"] + + +def test_international_core_is_order_independent_and_excludes_caste_point(): + a = chart(0, 30, 60, 180) + b = chart(90, 120, 150, 270) + forward = build_marriage_match_dossier(a, b)["ashtakoota_36"]["international_core"] + reverse = build_marriage_match_dossier(b, a)["ashtakoota_36"]["international_core"] + assert forward["total_score"] == reverse["total_score"] + assert forward["scores"] == reverse["scores"] + assert forward["max_score"] == 35.0 + assert "ValuesOrientation" not in forward["scores"] + assert "Varna" not in forward["scores"] + dialogue = build_marriage_match_dossier(a, b)["ashtakoota_36"]["shared_life_dialogue"] + assert dialogue["status"] == "not_scored" + + +def test_reported_directional_reproduction_is_order_independent_without_varna(): + # The historical directional calculation yields 10 vs 9 when 45 and 200 + # degrees are swapped because it includes Varna. The product core omits + # Varna and averages every included Kuta, so input order is immaterial. + a = chart(0, 45, 80, 120) + b = chart(90, 200, 170, 210) + forward = build_marriage_match_dossier(a, b)["ashtakoota_36"]["international_core"] + reverse = build_marriage_match_dossier(b, a)["ashtakoota_36"]["international_core"] + assert forward["total_score"] == reverse["total_score"] == 9.0 + assert forward["scores"] == reverse["scores"] + assert forward["directionality"]["status"] == "symmetric" + + +def test_zero_degree_moon_is_a_valid_ashtakoota_input(): + result = build_marriage_match_dossier(chart(0, 0, 20, 180), chart(90, 120, 150, 270)) + assert result["ashtakoota_36"]["status"] == "executed" + assert result["ashtakoota_36"]["international_core"]["person_a_details"]["moon_sign"] == "Aries" + + +def test_aries_named_ascendant_is_a_valid_lagna_reference(): + # Aries is sign index 0 and must not be discarded by a boolean fallback. + person_a = {"ascendant": "Aries", "planets": {"Mars": {"longitude": 120.0}}} + person_b = {"ascendant": "Taurus", "planets": {"Mars": {"longitude": 240.0}}} + kuja = build_marriage_match_dossier(person_a, person_b)["kuja_mangal_dosha"] + lagna_frame = kuja["person_a"]["frames"][0] + assert lagna_frame["reference"] == "lagna" + assert lagna_frame["reference_sign"] == "Aries" + assert lagna_frame["mars_house"] == 5 + assert lagna_frame["status"] == "not_triggered" + + +def test_moon_only_quick_reference_stays_neutral_and_marks_missing_pressure_layers(): + result = build_marriage_match_dossier( + {"planets": {"Moon": {"longitude": 0.0}}}, + {"planets": {"Moon": {"longitude": 120.0}}}, + ) + assert result["ashtakoota_36"]["status"] == "executed" + assert result["ashtakoota_36"]["international_core"]["total_score"] == 18.5 + assert result["kuja_mangal_dosha"]["person_a"]["status"] == "not_available" + assert result["kuja_mangal_dosha"]["person_b"]["status"] == "not_available" + + +def test_shared_life_dialogue_is_not_a_score_or_questionnaire_store(): + dialogue = build_marriage_match_dossier(chart(0, 30, 60, 180), chart(90, 120, 150, 270))["ashtakoota_36"]["shared_life_dialogue"] + assert dialogue["status"] == "not_scored" + assert dialogue["storage"] == "no_answers_collected" + assert dialogue["prompts"] + + +def test_non_romantic_scope_is_rejected(): + with pytest.raises(ValueError, match="romantic/marriage"): + build_marriage_match_dossier(chart(0, 1, 20, 180), chart(90, 4, 120, 270), relationship_type="business") + + +def test_api_handler_uses_the_new_product_contract_without_legacy_kuja_score(): + # 原值: JyotishAPIHandler._compute_marriage_match + # 新值: marriage_match_api.compute_marriage_match + # 原因: JyotishAPIHandler 类方法数不得增长,计算放独立模块。 + from marriage_match_api import compute_marriage_match + + payload = { + "person_a": chart(0, 1, 20, 180), + "person_b": chart(90, 4, 120, 270), + } + result = compute_marriage_match(payload) + assert result["product"] == "marriage_match_product_v1" + assert result["kuja_mangal_dosha"]["gender_asymmetry_used"] is False diff --git a/tests/test_open_source_integrations.py b/tests/test_open_source_integrations.py index 84113370..6d0f70cb 100644 --- a/tests/test_open_source_integrations.py +++ b/tests/test_open_source_integrations.py @@ -18,7 +18,7 @@ sys.path.insert(0, SCRIPTS) from jaimini import calc_arudha_padas, calc_graha_padas, calc_special_lagnas from argala import calc_argala -from synastry import calc_synastry +from ashtakoot import calculate_ashtakoot def sample_planet_longitudes(): @@ -64,11 +64,10 @@ def test_argala_enhanced_module(): def test_synastry_dashaflow_additional_kutas(): - result = calc_synastry( - {"moon_lon": 210.5, "mars_lon": 130.1, "asc_lon": 120.0, "gender": "M"}, - {"moon_lon": 45.2, "mars_lon": 15.0, "asc_lon": 60.0, "gender": "F"}, - ) - assert result["version"] == "3.8-dashaflow-mit-adapted" + # 原值: synastry.calc_synastry,并断言 version == 3.8-dashaflow-mit-adapted + # 新值: ashtakoot.calculate_ashtakoot 的 additional_kutas + # 原因: scripts/synastry.py 退役;附加 Kuta 仍在 ashtakoot.py + result = calculate_ashtakoot(210.5, 45.2) assert "additional_kutas" in result for key in ("Mahendra", "StreeDeergha", "Vedha", "Rajju", "BadConstellations"): assert key in result["additional_kutas"] diff --git a/tests/test_synastry_full_dossier.py b/tests/test_synastry_full_dossier.py new file mode 100644 index 00000000..7db99ab6 --- /dev/null +++ b/tests/test_synastry_full_dossier.py @@ -0,0 +1,64 @@ +"""Two public charts must produce a narrative-ready synastry dossier.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from jyotish_api_server import JyotishComputeService +from synastry_full_dossier import build_synastry_full_dossier + + +FIXTURE = ROOT / "frontend" / "tests" / "fixtures" / "synastry-full-dossier-truncated.json" + +# Public Rodden-rated charts already used by tests/verify_jyotish_v6_1.py. +# Times are the UT hours stored there, passed with tz 0. +EINSTEIN = { + "year": 1879, "month": 3, "day": 14, "hour": 10, "minute": 30, "second": 0, + "lat": 48.4, "lon": 10.0, "tz": 0, "ayanamsa": "lahiri", "node_mode": "mean", + "today": "2026-10-18", +} +JOBS = { + "year": 1955, "month": 2, "day": 25, "hour": 3, "minute": 15, "second": 0, + "lat": 37.77, "lon": -122.42, "tz": 0, "ayanamsa": "lahiri", "node_mode": "mean", + "today": "2026-10-18", +} + + +def truncate_dossier(packet: dict) -> dict: + """Keep the readiness and product identity. Drop longitudes and module bodies. + + The dropped fields are still produced by the engine; this slice is what the + fixture locks so the file does not carry a full private packet. + """ + dossier = packet["dossier"] + match = dossier["marriage_match"] + core = match["ashtakoota_36"]["international_core"] + snapshots = dossier["chart_snapshots"] + return { + "schema": dossier["schema"], + "delivery_readiness": dossier["delivery_readiness"], + "default_profile": match["rule_profile"]["id"], + "total_score": core["total_score"], + "max_score": core["max_score"], + "raman_profile": packet["raman_marriage_match"]["rule_profile"]["id"], + "planets_a": sorted(snapshots["person_a"]["planets"]), + "planets_b": sorted(snapshots["person_b"]["planets"]), + } + + +def test_public_pair_dossier_is_ready_for_narrative(): + handler = JyotishComputeService() + packet = build_synastry_full_dossier(handler, EINSTEIN, JOBS) + assert packet["dossier"]["delivery_readiness"] == "ready_for_narrative" + assert packet["raman_marriage_match"]["rule_profile"]["id"] == "raman_muhurtha_v1" + sliced = truncate_dossier(packet) + if not FIXTURE.is_file(): + FIXTURE.write_text(json.dumps(sliced, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + assert sliced == json.loads(FIXTURE.read_text(encoding="utf-8"))