mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
feat(pipeline): make hook retrieval lifecycle-aware
This commit is contained in:
@@ -896,6 +896,108 @@ describe("retrieveMemorySelection", () => {
|
||||
expect(result.hooks.map((hook) => hook.hookId)).not.toContain("stale-resolved");
|
||||
});
|
||||
|
||||
it("surfaces multiple stale hook families when debt pressure clusters instead of only one stale extra", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-stale-cluster-test-"));
|
||||
const bookDir = join(root, "book");
|
||||
const storyDir = join(bookDir, "story");
|
||||
const stateDir = join(storyDir, "state");
|
||||
await mkdir(stateDir, { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(stateDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
language: "en",
|
||||
lastAppliedChapter: 50,
|
||||
projectionVersion: 1,
|
||||
migrationWarnings: [],
|
||||
}, null, 2),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(stateDir, "current_state.json"),
|
||||
JSON.stringify({
|
||||
chapter: 50,
|
||||
facts: [],
|
||||
}, null, 2),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(stateDir, "chapter_summaries.json"),
|
||||
JSON.stringify({
|
||||
rows: [],
|
||||
}, null, 2),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(stateDir, "hooks.json"),
|
||||
JSON.stringify({
|
||||
hooks: [
|
||||
{
|
||||
hookId: "recent-route",
|
||||
startChapter: 47,
|
||||
type: "route",
|
||||
status: "open",
|
||||
lastAdvancedChapter: 49,
|
||||
expectedPayoff: "Recent route payoff",
|
||||
notes: "Recent route remains active.",
|
||||
},
|
||||
{
|
||||
hookId: "recent-guild",
|
||||
startChapter: 46,
|
||||
type: "politics",
|
||||
status: "progressing",
|
||||
lastAdvancedChapter: 48,
|
||||
expectedPayoff: "Guild payoff",
|
||||
notes: "Recent guild pressure remains active.",
|
||||
},
|
||||
{
|
||||
hookId: "recent-token",
|
||||
startChapter: 45,
|
||||
type: "artifact",
|
||||
status: "open",
|
||||
lastAdvancedChapter: 47,
|
||||
expectedPayoff: "Token payoff",
|
||||
notes: "Recent token route remains active.",
|
||||
},
|
||||
{
|
||||
hookId: "stale-omega",
|
||||
startChapter: 6,
|
||||
type: "relationship",
|
||||
status: "open",
|
||||
lastAdvancedChapter: 12,
|
||||
expectedPayoff: "Old relic payoff",
|
||||
notes: "Dormant unresolved relationship line.",
|
||||
},
|
||||
{
|
||||
hookId: "stale-sable",
|
||||
startChapter: 8,
|
||||
type: "mystery",
|
||||
status: "open",
|
||||
lastAdvancedChapter: 14,
|
||||
expectedPayoff: "Archive payoff",
|
||||
notes: "Dormant unresolved mystery line.",
|
||||
},
|
||||
],
|
||||
}, null, 2),
|
||||
"utf-8",
|
||||
),
|
||||
]);
|
||||
|
||||
const result = await retrieveMemorySelection({
|
||||
bookDir,
|
||||
chapterNumber: 51,
|
||||
goal: "Keep the chapter on the debt cluster and route pressure together.",
|
||||
mustKeep: ["The old debt cluster must stay legible."],
|
||||
});
|
||||
|
||||
expect(result.hooks.map((hook) => hook.hookId)).toEqual(expect.arrayContaining([
|
||||
"stale-omega",
|
||||
"stale-sable",
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not surface far-future unstarted hooks in early chapter retrieval", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-future-hook-gate-test-"));
|
||||
const bookDir = join(root, "book");
|
||||
|
||||
@@ -390,7 +390,10 @@ function resolveAgendaLimit(params: {
|
||||
}
|
||||
|
||||
function selectAgendaHooksWithTypeSpread<T extends {
|
||||
readonly hook: ReturnType<typeof normalizeStoredHook>;
|
||||
readonly hook: {
|
||||
readonly hookId: string;
|
||||
readonly type: string;
|
||||
};
|
||||
readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
|
||||
}>(params: {
|
||||
readonly entries: ReadonlyArray<T>;
|
||||
@@ -455,6 +458,65 @@ function normalizeHookType(type: string): string {
|
||||
return type.trim().toLowerCase() || "hook";
|
||||
}
|
||||
|
||||
function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{
|
||||
readonly hook: {
|
||||
readonly type: string;
|
||||
};
|
||||
readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
|
||||
}>): number {
|
||||
const pressuredCount = entries.filter((entry) =>
|
||||
entry.lifecycle.readyToResolve
|
||||
|| entry.lifecycle.stale
|
||||
|| entry.lifecycle.overdue,
|
||||
).length;
|
||||
return pressuredCount >= 4 ? 4 : 3;
|
||||
}
|
||||
|
||||
function resolveRelevantHookStaleLimit(
|
||||
entries: ReadonlyArray<{
|
||||
readonly hook: {
|
||||
readonly hookId: string;
|
||||
readonly type: string;
|
||||
};
|
||||
readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
|
||||
}>,
|
||||
selectedIds: ReadonlySet<string>,
|
||||
): number {
|
||||
const staleCandidates = entries.filter((entry) =>
|
||||
!selectedIds.has(entry.hook.hookId)
|
||||
&& (entry.lifecycle.stale || entry.lifecycle.overdue),
|
||||
);
|
||||
if (staleCandidates.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const staleFamilies = new Set(
|
||||
staleCandidates.map((entry) => normalizeHookType(entry.hook.type)),
|
||||
).size;
|
||||
const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length;
|
||||
if (overdueCount >= 2 || staleFamilies >= 2) {
|
||||
return Math.min(2, staleCandidates.length);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isHookWithinLifecycleWindow(
|
||||
hook: StoredHook,
|
||||
chapterNumber: number,
|
||||
lifecycle: ReturnType<typeof describeHookLifecycle>,
|
||||
): boolean {
|
||||
const recentWindow = lifecycle.timing === "endgame"
|
||||
? 10
|
||||
: lifecycle.timing === "slow-burn"
|
||||
? 8
|
||||
: lifecycle.timing === "mid-arc"
|
||||
? 6
|
||||
: 5;
|
||||
|
||||
return isHookWithinChapterWindow(hook, chapterNumber, recentWindow);
|
||||
}
|
||||
|
||||
function isMustAdvanceCandidate(
|
||||
lifecycle: ReturnType<typeof describeHookLifecycle>,
|
||||
): boolean {
|
||||
@@ -890,6 +952,15 @@ function selectRelevantHooks(
|
||||
const ranked = hooks
|
||||
.map((hook) => ({
|
||||
hook,
|
||||
lifecycle: describeHookLifecycle({
|
||||
payoffTiming: hook.payoffTiming,
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
notes: hook.notes,
|
||||
startChapter: Math.max(0, hook.startChapter),
|
||||
lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
|
||||
status: hook.status,
|
||||
chapterNumber,
|
||||
}),
|
||||
score: scoreHook(hook, queryTerms, chapterNumber),
|
||||
matched: matchesAny(
|
||||
[hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
|
||||
@@ -898,26 +969,30 @@ function selectRelevantHooks(
|
||||
}))
|
||||
.filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status));
|
||||
|
||||
const recentCutoff = Math.max(0, chapterNumber - 5);
|
||||
const staleCutoff = Math.max(0, chapterNumber - 10);
|
||||
const primary = ranked
|
||||
.filter((entry) => (
|
||||
entry.matched
|
||||
|| isHookWithinChapterWindow(entry.hook, chapterNumber, 5)
|
||||
))
|
||||
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
|
||||
.slice(0, 3);
|
||||
const primary = selectAgendaHooksWithTypeSpread({
|
||||
entries: ranked
|
||||
.filter((entry) => (
|
||||
entry.matched
|
||||
|| isHookWithinLifecycleWindow(entry.hook, chapterNumber, entry.lifecycle)
|
||||
))
|
||||
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter),
|
||||
limit: resolveRelevantHookPrimaryLimit(ranked),
|
||||
forceInclude: (entry) => entry.matched && entry.lifecycle.overdue,
|
||||
});
|
||||
|
||||
const selectedIds = new Set(primary.map((entry) => entry.hook.hookId));
|
||||
const stale = ranked
|
||||
.filter((entry) => (
|
||||
!selectedIds.has(entry.hook.hookId)
|
||||
&& !isFuturePlannedHook(entry.hook, chapterNumber)
|
||||
&& entry.hook.lastAdvancedChapter <= staleCutoff
|
||||
&& isUnresolvedHook(entry.hook.status)
|
||||
))
|
||||
.sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score)
|
||||
.slice(0, 1);
|
||||
const stale = selectAgendaHooksWithTypeSpread({
|
||||
entries: ranked
|
||||
.filter((entry) => (
|
||||
!selectedIds.has(entry.hook.hookId)
|
||||
&& !isFuturePlannedHook(entry.hook, chapterNumber)
|
||||
&& (entry.lifecycle.stale || entry.lifecycle.overdue)
|
||||
&& isUnresolvedHook(entry.hook.status)
|
||||
))
|
||||
.sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score),
|
||||
limit: resolveRelevantHookStaleLimit(ranked, selectedIds),
|
||||
forceInclude: (entry) => entry.lifecycle.overdue,
|
||||
});
|
||||
|
||||
return [...primary, ...stale].map((entry) => entry.hook);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user