Import the remade decks on stable ids and recommend one card per job, so a saved draft still loads after a chip is renamed.

The real-group profiles stay as a test, so a change that moves a pick fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-04 14:22:54 -07:00
1 parent 96ca15d8e3
commit bcbc6f30d9
89 files changed
+36555 -2828

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
/**
* 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)) };
}