Files
Jyotisha/frontend/src/lib/consultation-judgment-table.ts
T
jesse-ux d6eb0cfec1 fix(consult): keep career row hints off the planet nature
Career significator and house-lord rows now carry one presentation sentence. Values stay the same. A second three-repeat biography run still has one field conflict, so this is not accepted.

Not pushed.
2026-10-09 16:25:59 +08:00

453 lines
18 KiB
TypeScript

/**
* 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<string, never> {
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, unknown>,
houses: Record<string, unknown>,
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<string, unknown>, houses: Record<string, unknown>): 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<string, unknown>, houses: Record<string, unknown>): 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<string, unknown>): boolean {
return leaf(row.aspected_by).status === "value" || leaf(row.aspects_houses).status === "value";
}
function vargottama(planetRow: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
houses: Record<string, unknown>,
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<string, unknown>): 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<number> {
const found = new Set<number>();
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<number>): 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<string>();
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<string, unknown>;
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;
}