/** * One judgment table per consult domain (TASK-consult-card-full-source-20261007). * * The Python facts module copies packet leaves. This file only lays those * leaves into rows and columns. It does not round, translate, or decide that * an affliction count means anything. */ type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; type JsonRecord = { [key: string]: Json }; export const JUDGMENT_TABLE_LABELS = { rahuNote: "罗计不单独定功能吉凶", precision: "出生时间精度不够,不参与", relationLord: "主管", relationOccupant: "落入", relationAspect: "照", relationConjunctLord: "与宫主同宫", careerSignificatorReading: "这行只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。", } as const; const CAREER_JAIMINI_ROLES = new Set(["AmK", "DK", "PK", "MK", "PiK"]); /** Presentation only. Career significator rows keep their copied values. */ function careerSignificatorReading(domain: string, role: string | null): { reading: string } | Record { if (domain !== "career" || !role) return {}; const dasha = role.startsWith("Vimshottari"); const named = role === "natural" || role === "lord" || role === "Narayana MD lord" || CAREER_JAIMINI_ROLES.has(role); if (!dasha && !named) return {}; return { reading: JUDGMENT_TABLE_LABELS.careerSignificatorReading }; } const MALEFIC_BODIES = ["Saturn", "Mars", "Rahu", "Ketu"] as const; const ASPECT_MALEFICS = ["Saturn", "Mars"] as const; const DUSTHANA = new Set([6, 8, 12]); function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function isJson(value: unknown): value is Json { if (value === null || typeof value === "string" || typeof value === "boolean") return true; if (typeof value === "number") return Number.isFinite(value); if (Array.isArray(value)) return value.every(isJson); if (typeof value === "object") return Object.values(value as object).every(isJson); return false; } function copyJson(value: unknown): Json | undefined { if (!isJson(value)) return undefined; return structuredClone(value); } function leaf(value: unknown): JsonRecord { const row = record(value); const out: JsonRecord = {}; if (typeof row.status === "string") out.status = row.status; if (typeof row.path === "string") out.path = row.path; if (typeof row.check === "string") out.check = row.check; if (typeof row.note === "string") out.note = row.note; if ("value" in row) { const copied = copyJson(row.value); if (copied !== undefined) out.value = copied; else if (row.value === null) out.value = null; } if (typeof row.index === "number") out.index = row.index; return out; } function leafValue(value: unknown): Json | undefined { const row = leaf(value); if (row.status !== "value" && row.status !== "withheld") return undefined; return "value" in row ? row.value : undefined; } function textList(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function debilitated(status: unknown): boolean { return typeof status === "string" && (status.includes("Debilitated") || status.includes("落陷")) && !status.includes("取消"); } function houseCount(from: number, to: number): number { return ((to - from + 12) % 12) + 1; } type Detail = { signal: number; via: string; value: Json }; function afflictionForPlanet( planet: string, planets: Record, houses: Record, role: string | undefined, ): JsonRecord { const row = record(planets[planet]); const details: Detail[] = []; const unevaluated: string[] = []; const coTenants = textList(leafValue(row.co_tenants)); const withMalefic = MALEFIC_BODIES.filter((name) => coTenants.includes(name)); if (withMalefic.length) details.push({ signal: 1, via: "co_tenants", value: withMalefic }); const aspectLeaf = leaf(row.aspected_by); if (aspectLeaf.status !== "value") unevaluated.push("aspected_by"); else { const by = ASPECT_MALEFICS.filter((name) => textList(aspectLeaf.value).includes(name)); if (by.length) details.push({ signal: 1, via: "aspected_by", value: by }); } const house = leafValue(row.d1_house); const isLord = role === "lord" || [...Object.values(houses)].some((item) => leafValue(record(item).lord) === planet); if (isLord && typeof house === "number") { const evidence: Json[] = []; if (DUSTHANA.has(house)) evidence.push(house); for (const number of Object.keys(houses)) { const domainHouse = Number(number); if (!Number.isInteger(domainHouse)) continue; const count = houseCount(domainHouse, house); if (DUSTHANA.has(count)) evidence.push([domainHouse, count]); } if (evidence.length) details.push({ signal: 2, via: "d1_house", value: evidence }); } if (leafValue(row.functional_role) === "functional_malefics") { details.push({ signal: 3, via: "functional_role", value: "functional_malefics" }); } const dignity = leafValue(row.d1_dignity); if (debilitated(dignity)) details.push({ signal: 4, via: "d1_dignity", value: dignity as Json }); if (leafValue(row.combust) === true) details.push({ signal: 4, via: "combust", value: true }); const varga = leaf(row.domain_varga_house); if (varga.status === "withheld" || varga.status === "gap" || varga.status === undefined) { unevaluated.push("domain_varga_house"); } else if (isLord && typeof varga.value === "number" && DUSTHANA.has(varga.value)) { details.push({ signal: 5, via: "domain_varga_house", value: varga.value }); } return { count: details.length, details: details as Json[], unevaluated, }; } function afflictionForHouse(number: number, planets: Record, houses: Record): JsonRecord { const occupants = textList(leafValue(record(houses[String(number)]).occupants)); const malefic = MALEFIC_BODIES.filter((name) => occupants.includes(name)); const details: Detail[] = malefic.length ? [{ signal: 1, via: "occupants", value: malefic }] : []; const unevaluated = ["aspected_by"]; let vargaKnown = true; for (const name of MALEFIC_BODIES) { const varga = leaf(record(planets[name]).domain_varga_house); if (varga.status !== "value") { vargaKnown = false; continue; } if (varga.value === number) details.push({ signal: 5, via: "domain_varga_house", value: [name, number] }); } if (!vargaKnown) unevaluated.push("domain_varga_house"); return { count: details.length, details: details as Json[], unevaluated }; } function numberList(value: unknown): number[] { return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : []; } function relationsFor(planet: string, planets: Record, houses: Record): Json[] { const house = leafValue(record(planets[planet]).d1_house); const cast = new Set(numberList(leafValue(record(planets[planet]).aspects_houses))); const rows: Json[] = []; for (const [number, item] of Object.entries(houses)) { const domainHouse = Number(number); const lord = leafValue(record(item).lord); const labels: string[] = []; if (lord === planet) labels.push(JUDGMENT_TABLE_LABELS.relationLord); if (typeof house === "number" && house === domainHouse) labels.push(JUDGMENT_TABLE_LABELS.relationOccupant); if (cast.has(domainHouse)) labels.push(JUDGMENT_TABLE_LABELS.relationAspect); if (typeof lord === "string" && lord !== planet && typeof house === "number") { const lordHouse = leafValue(record(planets[lord]).d1_house); if (lordHouse === house) labels.push(JUDGMENT_TABLE_LABELS.relationConjunctLord); } rows.push({ house: domainHouse, labels }); } return rows; } function aspectKnown(row: Record): boolean { return leaf(row.aspected_by).status === "value" || leaf(row.aspects_houses).status === "value"; } function vargottama(planetRow: Record): JsonRecord { const d1 = leaf(planetRow.d1_sign); const d9 = leaf(planetRow.d9_sign); if (d1.status !== "value" || d9.status !== "value" || typeof d1.value !== "string" || typeof d9.value !== "string") { return { status: "gap", check: "derived" }; } return { status: "derived", value: d1.value === d9.value, derived_from: [d1.path ?? "", d9.path ?? ""], check: "derived", }; } function savOf(numbers: number[], houses: Record): Json[] { return numbers.map((number) => { const house = record(houses[String(number)]); return { house: number, sav_score: leaf(house.sav_score), sav_sodhita: leaf(house.sav_sodhita), }; }); } function coTenantRoles(planet: string, planets: Record): Json[] { const names = textList(leafValue(record(planets[planet]).co_tenants)); return names.map((name) => ({ planet: name, functional_role: leaf(record(planets[name]).functional_role), })); } function planetColumns( planet: string, planets: Record, houses: Record, role: string | undefined, ): JsonRecord { const row = record(planets[planet]); if (row.present === false || Object.keys(row).length === 0) { return { planet, status: "gap" }; } const domainHouses = Object.keys(houses).map(Number).filter((number) => Number.isInteger(number)); return { planet, d1_sign: leaf(row.d1_sign), d1_house: leaf(row.d1_house), d1_dignity: leaf(row.d1_dignity), functional_role: leaf(row.functional_role), rasi_lord: leaf(row.rasi_lord), co_tenants: leaf(row.co_tenants), co_tenant_roles: coTenantRoles(planet, planets), shadbala_rupas: leaf(row.shadbala_rupas), shadbala_min: leaf(row.shadbala_min), bav: leaf(row.bav), sav: savOf(domainHouses, houses), affliction: afflictionForPlanet(planet, planets, houses, role), d9_sign: leaf(row.d9_sign), d9_dignity: leaf(row.d9_dignity), vargottama: vargottama(row), domain_varga_house: leaf(row.domain_varga_house), aspected_by: leaf(row.aspected_by), aspects_houses: leaf(row.aspects_houses), relations: relationsFor(planet, planets, houses), relation_gaps: aspectKnown(row) ? [] : [JUDGMENT_TABLE_LABELS.relationAspect], }; } /** Assemble one domain table from copied facts. Missing leaves stay gaps. */ export function assembleJudgmentTable(facts: unknown): JsonRecord { const body = record(facts); const planets = record(body.planets); const houses = record(body.houses); const domain = typeof body.domain === "string" ? body.domain : ""; const rows: Json[] = []; const factors = Array.isArray(body.factors) ? body.factors : []; for (const factor of factors) { const item = record(factor); const id = typeof item.id === "string" ? item.id : "factor"; const citation = typeof item.citation === "string" ? item.citation : ""; if (item.kind === "house" && typeof item.house === "number") { const house = record(houses[String(item.house)]); rows.push({ id, kind: "house", citation, house: item.house, sign: leaf(house.sign), lord: leaf(house.lord), occupants: leaf(house.occupants), sav: savOf([item.house], houses)[0] ?? null, affliction: afflictionForHouse(item.house, planets, houses), aspected_by: leaf(house.aspected_by), relation_gaps: leaf(house.aspected_by).status === "value" ? [] : [JUDGMENT_TABLE_LABELS.relationAspect], }); const lord = leafValue(house.lord); if (typeof lord === "string") { rows.push({ id: `${id}:lord`, kind: "planet", role: "lord", citation, ...planetColumns(lord, planets, houses, "lord"), ...careerSignificatorReading(domain, "lord"), }); } else { rows.push({ id: `${id}:lord`, kind: "gap", citation, path: leaf(house.lord).path ?? null }); } continue; } if (item.kind === "planet" && typeof item.planet === "string") { const role = typeof item.role === "string" ? item.role : null; rows.push({ id, kind: "planet", role, citation, ...planetColumns(item.planet, planets, houses, role ?? undefined), ...careerSignificatorReading(domain, role), }); continue; } if (item.kind === "sign") { rows.push({ id, kind: "sign", citation, sign: leaf(item.sign), lord: leaf(item.lord), affliction: { count: null, details: [], unevaluated: ["sign_row"] }, }); continue; } rows.push({ id, kind: typeof item.kind === "string" ? item.kind : "gap", citation, ...(typeof item.path === "string" ? { path: item.path } : {}), }); } return { header_note: typeof body.header_note === "string" ? body.header_note : JUDGMENT_TABLE_LABELS.rahuNote, domain, domain_varga: typeof body.domain_varga === "string" ? body.domain_varga : "", rows, }; } const MACHINE_KEYS = new Set(["path", "check", "derived_from", "member", "index"]); function isLeafRecord(value: Record): boolean { return typeof value.status === "string" && ("path" in value || "check" in value || "value" in value || value.status === "gap" || value.status === "blocked"); } function filledRelations(value: Json | undefined): Json[] { if (!Array.isArray(value)) return []; return value.filter((item) => Array.isArray((item as JsonRecord | null)?.labels) && (item as JsonRecord).labels && ((item as JsonRecord).labels as Json[]).length > 0); } function housesWithSav(rows: Json[]): Set { const found = new Set(); for (const item of rows) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; const sav = (item as JsonRecord).sav; if ((item as JsonRecord).kind !== "house" || !sav || typeof sav !== "object" || Array.isArray(sav)) continue; const house = (sav as JsonRecord).house; if (typeof house === "number") found.add(house); } return found; } function savAlreadyOnHouseRows(sav: Json | undefined, covered: Set): boolean { if (!Array.isArray(sav) || sav.length === 0) return false; return sav.every((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return false; const house = (item as JsonRecord).house; return typeof house === "number" && covered.has(house); }); } /** * The server table repeats one planet's columns on every factor row. * The model keeps that planet's values once. A later row keeps the role * and the affliction count, which can change with the role. SAV stays on * the house row when that row already carries it. */ function compactPlanetRows(rows: Json[]): Json[] { const covered = housesWithSav(rows); const seen = new Set(); return rows.map((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const row = { ...(item as JsonRecord) }; if (row.kind !== "planet" || typeof row.planet !== "string") return row; const relations = filledRelations(row.relations as Json | undefined); if (!seen.has(row.planet)) { seen.add(row.planet); if (savAlreadyOnHouseRows(row.sav as Json | undefined, covered)) delete row.sav; if (relations.length) row.relations = relations; else delete row.relations; return row; } const repeat: JsonRecord = { id: row.id, kind: row.kind, planet: row.planet }; if (typeof row.role === "string") repeat.role = row.role; if (typeof row.citation === "string") repeat.citation = row.citation; if (typeof row.reading === "string") repeat.reading = row.reading; if (row.affliction !== undefined) repeat.affliction = row.affliction; return repeat; }); } /** Values for the model. Internal paths and the check field stay off this view. */ export function judgmentTableModelView(value: unknown): Json { const slimmed = slimModel(value); if (!slimmed || typeof slimmed !== "object" || Array.isArray(slimmed)) return slimmed; const body = slimmed as JsonRecord; if (!Array.isArray(body.rows)) return slimmed; return { ...body, rows: compactPlanetRows(body.rows) }; } export function participationModelView(value: unknown): Json { if (!Array.isArray(value)) return []; const rows: Json[] = []; for (const item of value) { const row = record(item); const out: JsonRecord = {}; if (typeof row.id === "string") out.id = row.id; if (row.status === "value" && "value" in row) { const copied = copyJson(row.value); out.value = copied === undefined ? null : copied; if (typeof row.citation === "string") out.citation = row.citation; } else if (row.status === "withheld" && typeof row.value === "string") { out.value = row.value; if (typeof row.citation === "string") out.citation = row.citation; } else if (row.status === "gap" || row.status === "blocked") { out.status = row.status; } else { continue; } rows.push(out); } return rows; } function slimModel(value: unknown): Json { if (value === null || typeof value === "string" || typeof value === "boolean") return value; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (Array.isArray(value)) return value.map((item) => slimModel(item)); if (!value || typeof value !== "object") return null; const row = value as Record; if (isLeafRecord(row)) { if ((row.status === "value" || row.status === "derived") && "value" in row) { const copied = copyJson(row.value); return copied === undefined ? null : slimModel(copied); } if (row.status === "withheld") return typeof row.value === "string" ? row.value : "withheld"; if (row.status === "gap" || row.status === "blocked" || row.status === "value") return row.status; } const out: JsonRecord = {}; for (const [key, item] of Object.entries(row)) { if (MACHINE_KEYS.has(key)) continue; out[key] = slimModel(item); } return out; }