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:
1 parent
96ca15d8e3
commit
bcbc6f30d9
89 files changed
+36555
-2828
No files matched your search
@@ -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)) };
|
||||
}
|
||||
Reference in new issue
Block a user