The real-group profiles stay as a test, so a change that moves a pick fails. Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/**
|
|
* Reference engine for RECOMMENDED and the seven, as agreed on 24 and 27 October 2026.
|
|
*
|
|
* Pure functions with no I/O, written to be ported into
|
|
* useFacetRecommendations.ts (deriveCompactCards) and methodRecommendations.ts.
|
|
* The README in this package states the same rule in words.
|
|
*/
|
|
import communicationFacets from "../../data/create/customRule/communication.json";
|
|
import conflictFacets from "../../data/create/customRule/conflictManagement.json";
|
|
import decisionFacets from "../../data/create/customRule/decisionApproaches.json";
|
|
import jobsFile from "../../data/create/customRule/_jobs.json";
|
|
import membershipFacets from "../../data/create/customRule/membership.json";
|
|
import type {
|
|
CardEngineEntry,
|
|
DeckEngine,
|
|
DeckId,
|
|
DeckRecommendation,
|
|
FacetFile,
|
|
GroupAnswers,
|
|
JobsFile,
|
|
Kind,
|
|
MatchedFacet,
|
|
RankTier,
|
|
RecommendedCard,
|
|
RecommendationReason,
|
|
SizeBand,
|
|
TraitKey,
|
|
} from "./deckTypes";
|
|
|
|
export interface DeckData {
|
|
deck: DeckId;
|
|
facets: FacetFile;
|
|
engine: DeckEngine;
|
|
config: Pick<
|
|
JobsFile,
|
|
"displayLimit" | "maxAddedInSeven" | "tierOrder" | "traitKeys" | "sizeAnswers"
|
|
>;
|
|
}
|
|
|
|
export interface RecommendOptions {
|
|
/** How many recommended cards show before See all. A screen that shows five passes 5. */
|
|
limit?: number;
|
|
}
|
|
|
|
interface Evaluated {
|
|
id: string;
|
|
entry: CardEngineEntry;
|
|
eligible: boolean;
|
|
matched: MatchedFacet[];
|
|
fitsAnswer?: TraitKey;
|
|
openedBy?: TraitKey;
|
|
score: number;
|
|
otherKindHits: number;
|
|
}
|
|
|
|
/** Normalizes setup answers. A group with other kinds and no main kind gets the first as main. */
|
|
export function normalizeAnswers(answers: GroupAnswers): Required<GroupAnswers> {
|
|
const others = [...(answers.otherKinds ?? [])];
|
|
const mainKind = answers.mainKind ?? others.shift() ?? null;
|
|
return {
|
|
size: answers.size ?? null,
|
|
mainKind,
|
|
otherKinds: others.filter((k) => k !== mainKind),
|
|
scale: [...new Set(answers.scale ?? [])],
|
|
stage: answers.stage ?? null,
|
|
traits: answers.traits ?? {},
|
|
};
|
|
}
|
|
|
|
function sizeBandOf(size: string | null, data: DeckData): SizeBand | null {
|
|
if (!size) return null;
|
|
const band = data.config.sizeAnswers[size];
|
|
if (!band) throw new Error(`Unknown size answer ${size}`);
|
|
return band;
|
|
}
|
|
|
|
function evaluate(
|
|
id: string,
|
|
data: DeckData,
|
|
a: Required<GroupAnswers>,
|
|
band: SizeBand | null,
|
|
ticked: Set<TraitKey>,
|
|
): Evaluated {
|
|
const f = data.facets[id];
|
|
const entry = data.engine.cards[id];
|
|
if (!f || !entry) throw new Error(`Card ${id} is missing from the facet or jobs file`);
|
|
const traits = entry.traits ?? {};
|
|
const fitsSize = band ? f.size[band] : true;
|
|
const fitsKind = a.mainKind ? f.orgType[a.mainKind] : true;
|
|
const openedBy = (traits.opens ?? []).find((k) => ticked.has(k));
|
|
const isBeside = (traits.beside ?? []).length > 0;
|
|
// Each facet group counts once. Scale earns its point only if the card fits
|
|
// every scale the group ticked (27 Oct, call 2).
|
|
const fitsScale = a.scale.length > 0 && a.scale.every((s) => f.scale[s]);
|
|
const fitsStage = a.stage !== null && f.maturity[a.stage];
|
|
const fitsAnswer = (traits.fits ?? []).find((k) => ticked.has(k));
|
|
const matched: MatchedFacet[] = [];
|
|
if (band && fitsSize) matched.push("size");
|
|
if (a.mainKind && fitsKind) matched.push("kind");
|
|
if (fitsScale) matched.push("scale");
|
|
if (fitsStage) matched.push("stage");
|
|
return {
|
|
id,
|
|
entry,
|
|
// Size and the main kind decide eligibility, or a ticked answer opens the card.
|
|
// A card an answer adds never competes for a job.
|
|
eligible: !isBeside && fitsSize && (fitsKind || openedBy !== undefined),
|
|
matched,
|
|
fitsAnswer,
|
|
openedBy,
|
|
// Inside a job, scale, stage and the one point from answers decide (27 Oct, call 1).
|
|
score: (fitsScale ? 1 : 0) + (fitsStage ? 1 : 0) + (fitsAnswer ? 1 : 0),
|
|
otherKindHits: a.otherKinds.filter((k: Kind) => f.orgType[k]).length,
|
|
};
|
|
}
|
|
|
|
/** Best first. Score, then other kinds, then row order inside the job. */
|
|
function compareCandidates(x: Evaluated, y: Evaluated): number {
|
|
return (
|
|
y.score - x.score ||
|
|
y.otherKindHits - x.otherKindHits ||
|
|
x.entry.priority - y.entry.priority
|
|
);
|
|
}
|
|
|
|
/** What separated the winner from the runner-up. */
|
|
function decidedBy(c: Evaluated[]): RecommendedCard["decidedBy"] {
|
|
if (c.length < 2) return "only";
|
|
if (c[0].score !== c[1].score) return "score";
|
|
if (c[0].otherKindHits !== c[1].otherKindHits) return "otherKinds";
|
|
return "rowOrder";
|
|
}
|
|
|
|
export function recommendDeck(
|
|
data: DeckData,
|
|
answers: GroupAnswers,
|
|
options: RecommendOptions = {},
|
|
): DeckRecommendation {
|
|
const limit = options.limit ?? data.config.displayLimit;
|
|
const a = normalizeAnswers(answers);
|
|
const band = sizeBandOf(a.size, data);
|
|
const ticked = new Set<TraitKey>(data.config.traitKeys.filter((k) => a.traits[k] === true));
|
|
const jobs = data.engine.jobs;
|
|
const jobIndex = new Map(jobs.map((j, i) => [j.id, i]));
|
|
const allInDeckOrder = deckOrder(data);
|
|
|
|
const anyFacet = band !== null || a.mainKind !== null || a.scale.length > 0 || a.stage !== null;
|
|
const fallback = (): DeckRecommendation => ({
|
|
deck: data.deck,
|
|
fallback: true,
|
|
recommended: [],
|
|
shown: [],
|
|
waiting: [],
|
|
seeAll: allInDeckOrder,
|
|
});
|
|
if (!anyFacet) return fallback();
|
|
|
|
const cardIds = Object.keys(data.engine.cards);
|
|
const evaluated = new Map(cardIds.map((id) => [id, evaluate(id, data, a, band, ticked)]));
|
|
const tierRank = (t: RankTier) => data.config.tierOrder.indexOf(t);
|
|
const recommended: (RecommendedCard & { sortKey: number[]; showKey: number[] })[] = [];
|
|
|
|
// One card per job that has an eligible card. The runner-up is its alternative.
|
|
for (const job of jobs) {
|
|
if (job.displayTier === "none") continue;
|
|
const candidates = [...evaluated.values()]
|
|
.filter((e) => e.entry.job === job.id && e.eligible)
|
|
.sort(compareCandidates);
|
|
const winner = candidates[0];
|
|
if (!winner) continue;
|
|
const reason: RecommendationReason =
|
|
winner.openedBy && !winner.matched.includes("kind")
|
|
? { kind: "opened", matched: winner.matched, answer: winner.openedBy }
|
|
: { kind: "fit", matched: winner.matched, ...(winner.fitsAnswer ? { answer: winner.fitsAnswer } : {}) };
|
|
recommended.push({
|
|
id: winner.id,
|
|
job: job.id,
|
|
showsWith: job.id,
|
|
tier: job.displayTier as RankTier,
|
|
tag: winner.entry.everyRuleHas ? "everyRuleHas" : "recommended",
|
|
reason,
|
|
...(candidates[1] ? { alternative: candidates[1].id } : {}),
|
|
score: winner.score,
|
|
decidedBy: decidedBy(candidates),
|
|
sortKey: [tierRank(job.displayTier as RankTier), job.rank ?? 0, 0],
|
|
showKey: [jobIndex.get(job.id)!, 0, 0],
|
|
});
|
|
}
|
|
|
|
// Cards a ticked answer adds beside a job, whatever the group's size or kind.
|
|
const added = data.engine.addedOrder.filter((id) => {
|
|
const e = evaluated.get(id)!;
|
|
return (e.entry.traits?.beside ?? []).some((k) => ticked.has(k));
|
|
});
|
|
added.forEach((id, i) => {
|
|
const e = evaluated.get(id)!;
|
|
const besideJob = e.entry.traits!.besideJob!;
|
|
const answer = e.entry.traits!.beside!.find((k) => ticked.has(k))!;
|
|
const inSevenSlot = i < data.config.maxAddedInSeven;
|
|
recommended.push({
|
|
id,
|
|
job: e.entry.job,
|
|
showsWith: besideJob,
|
|
tier: "answers",
|
|
tag: e.entry.everyRuleHas ? "everyRuleHas" : "recommended",
|
|
reason: { kind: "added", answer },
|
|
// The first added cards rank right after the safety jobs, any more rank last.
|
|
sortKey: inSevenSlot ? [tierRank("answers"), 0, i] : [data.config.tierOrder.length, 0, i],
|
|
showKey: [jobIndex.get(besideJob)!, 1, i],
|
|
});
|
|
});
|
|
|
|
if (recommended.length === 0) return fallback();
|
|
|
|
const compareKeys = (x: number[], y: number[]) => {
|
|
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
return 0;
|
|
};
|
|
// Rank decides which cards show. Deck order decides where they show.
|
|
const ranked = [...recommended].sort((x, y) => compareKeys(x.sortKey, y.sortKey));
|
|
const shownIds = new Set(ranked.slice(0, limit).map((r) => r.id));
|
|
const strip = ({ sortKey: _s, showKey: _w, ...card }: (typeof recommended)[number]): RecommendedCard => card;
|
|
const inShowOrder = [...recommended].sort((x, y) => compareKeys(x.showKey, y.showKey));
|
|
const shown = inShowOrder.filter((r) => shownIds.has(r.id)).map(strip);
|
|
const waiting = inShowOrder.filter((r) => !shownIds.has(r.id)).map(strip);
|
|
const recommendedIds = new Set(recommended.map((r) => r.id));
|
|
|
|
return {
|
|
deck: data.deck,
|
|
fallback: false,
|
|
recommended: ranked.map(strip),
|
|
shown,
|
|
waiting,
|
|
seeAll: [...waiting.map((r) => r.id), ...allInDeckOrder.filter((id) => !recommendedIds.has(id))],
|
|
};
|
|
}
|
|
|
|
/** Every card in deck order, job by job, then by priority inside each job. */
|
|
export function deckOrder(data: DeckData): string[] {
|
|
const jobIndex = new Map(data.engine.jobs.map((j, i) => [j.id, i]));
|
|
const key = (id: string) => {
|
|
const e = data.engine.cards[id];
|
|
return (jobIndex.get(e.job) ?? data.engine.jobs.length) * 1000 + e.priority;
|
|
};
|
|
return Object.keys(data.engine.cards).sort((x, y) => key(x) - key(y));
|
|
}
|
|
|
|
/**
|
|
* The template path (27 Oct). A group that starts from a template answers no setup
|
|
* question and gets no recommendations. Each deck shows the template's cards first,
|
|
* in the template's order, with no tag and no seven-card cut, then the rest of the
|
|
* deck in deck order. templateCardIds come from templateChips.json.
|
|
*/
|
|
const jobs = jobsFile as JobsFile;
|
|
const FACETS: Record<DeckId, FacetFile> = {
|
|
decisionApproaches: decisionFacets as FacetFile,
|
|
membership: membershipFacets as FacetFile,
|
|
conflictManagement: conflictFacets as FacetFile,
|
|
communication: communicationFacets as FacetFile,
|
|
};
|
|
|
|
/** One deck's facets, jobs and display config, ready for {@link recommendDeck}. */
|
|
export function deckData(deck: DeckId): DeckData {
|
|
return {
|
|
deck,
|
|
facets: FACETS[deck],
|
|
engine: jobs.decks[deck],
|
|
config: {
|
|
displayLimit: jobs.displayLimit,
|
|
maxAddedInSeven: jobs.maxAddedInSeven,
|
|
tierOrder: jobs.tierOrder,
|
|
traitKeys: jobs.traitKeys,
|
|
sizeAnswers: jobs.sizeAnswers,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function templateDeck(data: DeckData, templateCardIds: string[]): { deck: DeckId; first: string[]; rest: string[] } {
|
|
const live = new Set(Object.keys(data.engine.cards));
|
|
const first = [...new Set(templateCardIds)].filter((id) => live.has(id));
|
|
const chosen = new Set(first);
|
|
return { deck: data.deck, first, rest: deckOrder(data).filter((id) => !chosen.has(id)) };
|
|
}
|