Преглед на файлове

refactor(pipeline): centralize hook policy constants

Ma преди 7 месеца
родител
ревизия
2b911816c6

+ 33 - 0
packages/core/src/__tests__/hook-policy.test.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import {
+  HOOK_AGENDA_LIMITS,
+  HOOK_HEALTH_DEFAULTS,
+  HOOK_PHASE_THRESHOLDS,
+  HOOK_PRESSURE_WEIGHTS,
+  HOOK_TIMING_PROFILES,
+  resolveHookVisibilityWindow,
+} from "../utils/hook-policy.js";
+
+describe("hook-policy", () => {
+  it("exposes shared lifecycle timing profiles from a single policy surface", () => {
+    expect(HOOK_TIMING_PROFILES.immediate.earliestResolveAge).toBe(1);
+    expect(HOOK_TIMING_PROFILES["slow-burn"].minimumPhase).toBe("middle");
+    expect(HOOK_TIMING_PROFILES.endgame.overdueAge).toBeGreaterThan(
+      HOOK_TIMING_PROFILES["near-term"].overdueAge,
+    );
+  });
+
+  it("widens visibility windows for longer-payoff hooks", () => {
+    expect(resolveHookVisibilityWindow("immediate")).toBe(5);
+    expect(resolveHookVisibilityWindow("mid-arc")).toBeGreaterThan(resolveHookVisibilityWindow("near-term"));
+    expect(resolveHookVisibilityWindow("endgame")).toBeGreaterThan(resolveHookVisibilityWindow("slow-burn"));
+  });
+
+  it("keeps shared agenda, phase, pressure, and health defaults together", () => {
+    expect(HOOK_AGENDA_LIMITS.light.mustAdvance).toBeLessThan(HOOK_AGENDA_LIMITS.heavy.mustAdvance);
+    expect(HOOK_AGENDA_LIMITS.heavy.eligibleResolve).toBeGreaterThan(HOOK_AGENDA_LIMITS.light.eligibleResolve);
+    expect(HOOK_HEALTH_DEFAULTS.maxActiveHooks).toBe(12);
+    expect(HOOK_PHASE_THRESHOLDS.lateProgress).toBeGreaterThan(HOOK_PHASE_THRESHOLDS.middleProgress);
+    expect(HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier).toBe(10);
+  });
+});

+ 5 - 4
packages/core/src/utils/hook-health.ts

@@ -2,6 +2,7 @@ import type { AuditIssue } from "../agents/continuity.js";
 import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
 import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js";
 import { describeHookLifecycle, localizeHookPayoffTiming } from "./hook-lifecycle.js";
+import { HOOK_HEALTH_DEFAULTS } from "./hook-policy.js";
 
 export function analyzeHookHealth(params: {
   readonly language: "zh" | "en";
@@ -15,10 +16,10 @@ export function analyzeHookHealth(params: {
   readonly noAdvanceWindow?: number;
   readonly newHookBurstThreshold?: number;
 }): AuditIssue[] {
-  const maxActiveHooks = params.maxActiveHooks ?? 12;
-  const staleAfterChapters = params.staleAfterChapters ?? 10;
-  const noAdvanceWindow = params.noAdvanceWindow ?? 5;
-  const newHookBurstThreshold = params.newHookBurstThreshold ?? 2;
+  const maxActiveHooks = params.maxActiveHooks ?? HOOK_HEALTH_DEFAULTS.maxActiveHooks;
+  const staleAfterChapters = params.staleAfterChapters ?? HOOK_HEALTH_DEFAULTS.staleAfterChapters;
+  const noAdvanceWindow = params.noAdvanceWindow ?? HOOK_HEALTH_DEFAULTS.noAdvanceWindow;
+  const newHookBurstThreshold = params.newHookBurstThreshold ?? HOOK_HEALTH_DEFAULTS.newHookBurstThreshold;
   const issues: AuditIssue[] = [];
 
   const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved");

+ 24 - 62
packages/core/src/utils/hook-lifecycle.ts

@@ -1,58 +1,11 @@
 import type { HookPayoffTiming } from "../models/runtime-state.js";
-
-type HookPhase = "opening" | "middle" | "late";
-
-interface LifecycleProfile {
-  readonly earliestResolveAge: number;
-  readonly staleDormancy: number;
-  readonly overdueAge: number;
-  readonly minimumPhase: HookPhase;
-  readonly resolveBias: number;
-}
-
-const TIMING_PROFILES: Record<HookPayoffTiming, LifecycleProfile> = {
-  immediate: {
-    earliestResolveAge: 1,
-    staleDormancy: 1,
-    overdueAge: 3,
-    minimumPhase: "opening",
-    resolveBias: 5,
-  },
-  "near-term": {
-    earliestResolveAge: 1,
-    staleDormancy: 2,
-    overdueAge: 5,
-    minimumPhase: "opening",
-    resolveBias: 4,
-  },
-  "mid-arc": {
-    earliestResolveAge: 2,
-    staleDormancy: 4,
-    overdueAge: 8,
-    minimumPhase: "opening",
-    resolveBias: 3,
-  },
-  "slow-burn": {
-    earliestResolveAge: 4,
-    staleDormancy: 5,
-    overdueAge: 12,
-    minimumPhase: "middle",
-    resolveBias: 2,
-  },
-  endgame: {
-    earliestResolveAge: 6,
-    staleDormancy: 6,
-    overdueAge: 16,
-    minimumPhase: "late",
-    resolveBias: 1,
-  },
-};
-
-const PHASE_WEIGHT: Record<HookPhase, number> = {
-  opening: 0,
-  middle: 1,
-  late: 2,
-};
+import {
+  HOOK_PHASE_THRESHOLDS,
+  HOOK_PHASE_WEIGHT,
+  HOOK_PRESSURE_WEIGHTS,
+  HOOK_TIMING_PROFILES,
+  type HookPhase,
+} from "./hook-policy.js";
 
 const LABELS: Record<"zh" | "en", Record<HookPayoffTiming, string>> = {
   en: {
@@ -159,13 +112,13 @@ export function describeHookLifecycle(params: {
   readonly resolvePressure: number;
 } {
   const timing = resolveHookPayoffTiming(params);
-  const profile = TIMING_PROFILES[timing];
+  const profile = HOOK_TIMING_PROFILES[timing];
   const phase = resolveHookPhase(params.chapterNumber, params.targetChapters);
   const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter));
   const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter);
   const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter));
   const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim());
-  const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase];
+  const phaseReady = HOOK_PHASE_WEIGHT[phase] >= HOOK_PHASE_WEIGHT[profile.minimumPhase];
   const recentlyTouched = dormancy <= 1;
   const overdue = phaseReady && age >= profile.overdueAge;
   const cadenceReady = timing === "slow-burn"
@@ -191,9 +144,18 @@ export function describeHookLifecycle(params: {
     readyToResolve,
     stale,
     overdue,
-    advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0),
+    advancePressure: age
+      + dormancy
+      + (stale ? HOOK_PRESSURE_WEIGHTS.staleAdvanceBonus : 0)
+      + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueAdvanceBonus : 0),
     resolvePressure: readyToResolve
-      ? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0)
+      ? profile.resolveBias * HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier
+        + (explicitProgressing ? HOOK_PRESSURE_WEIGHTS.progressingResolveBonus : 0)
+        + Math.min(
+          HOOK_PRESSURE_WEIGHTS.maxDormancyResolveBonus,
+          dormancy * HOOK_PRESSURE_WEIGHTS.dormancyResolveMultiplier,
+        )
+        + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueResolveBonus : 0)
       : 0,
   };
 }
@@ -201,12 +163,12 @@ export function describeHookLifecycle(params: {
 function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase {
   if (targetChapters && targetChapters > 0) {
     const progress = chapterNumber / targetChapters;
-    if (progress >= 0.72) return "late";
-    if (progress >= 0.33) return "middle";
+    if (progress >= HOOK_PHASE_THRESHOLDS.lateProgress) return "late";
+    if (progress >= HOOK_PHASE_THRESHOLDS.middleProgress) return "middle";
     return "opening";
   }
 
-  if (chapterNumber >= 24) return "late";
-  if (chapterNumber >= 8) return "middle";
+  if (chapterNumber >= HOOK_PHASE_THRESHOLDS.lateChapter) return "late";
+  if (chapterNumber >= HOOK_PHASE_THRESHOLDS.middleChapter) return "middle";
   return "opening";
 }

+ 145 - 0
packages/core/src/utils/hook-policy.ts

@@ -0,0 +1,145 @@
+import type { HookPayoffTiming } from "../models/runtime-state.js";
+
+export type HookPhase = "opening" | "middle" | "late";
+export type HookAgendaLoad = "light" | "medium" | "heavy";
+
+export interface HookLifecycleProfile {
+  readonly earliestResolveAge: number;
+  readonly staleDormancy: number;
+  readonly overdueAge: number;
+  readonly minimumPhase: HookPhase;
+  readonly resolveBias: number;
+}
+
+export const HOOK_TIMING_PROFILES: Record<HookPayoffTiming, HookLifecycleProfile> = {
+  immediate: {
+    earliestResolveAge: 1,
+    staleDormancy: 1,
+    overdueAge: 3,
+    minimumPhase: "opening",
+    resolveBias: 5,
+  },
+  "near-term": {
+    earliestResolveAge: 1,
+    staleDormancy: 2,
+    overdueAge: 5,
+    minimumPhase: "opening",
+    resolveBias: 4,
+  },
+  "mid-arc": {
+    earliestResolveAge: 2,
+    staleDormancy: 4,
+    overdueAge: 8,
+    minimumPhase: "opening",
+    resolveBias: 3,
+  },
+  "slow-burn": {
+    earliestResolveAge: 4,
+    staleDormancy: 5,
+    overdueAge: 12,
+    minimumPhase: "middle",
+    resolveBias: 2,
+  },
+  endgame: {
+    earliestResolveAge: 6,
+    staleDormancy: 6,
+    overdueAge: 16,
+    minimumPhase: "late",
+    resolveBias: 1,
+  },
+};
+
+export const HOOK_PHASE_WEIGHT: Record<HookPhase, number> = {
+  opening: 0,
+  middle: 1,
+  late: 2,
+};
+
+export const HOOK_PHASE_THRESHOLDS = {
+  middleProgress: 0.33,
+  lateProgress: 0.72,
+  middleChapter: 8,
+  lateChapter: 24,
+} as const;
+
+export const HOOK_PRESSURE_WEIGHTS = {
+  staleAdvanceBonus: 8,
+  overdueAdvanceBonus: 6,
+  resolveBiasMultiplier: 10,
+  progressingResolveBonus: 5,
+  dormancyResolveMultiplier: 2,
+  maxDormancyResolveBonus: 12,
+  overdueResolveBonus: 10,
+  mustAdvancePressureFloor: 8,
+  criticalResolvePressure: 40,
+} as const;
+
+export const HOOK_AGENDA_LIMITS: Record<HookAgendaLoad, {
+  readonly staleDebt: number;
+  readonly mustAdvance: number;
+  readonly eligibleResolve: number;
+  readonly avoidFamilies: number;
+}> = {
+  light: {
+    staleDebt: 1,
+    mustAdvance: 2,
+    eligibleResolve: 1,
+    avoidFamilies: 2,
+  },
+  medium: {
+    staleDebt: 2,
+    mustAdvance: 2,
+    eligibleResolve: 1,
+    avoidFamilies: 3,
+  },
+  heavy: {
+    staleDebt: 3,
+    mustAdvance: 3,
+    eligibleResolve: 2,
+    avoidFamilies: 4,
+  },
+};
+
+export const HOOK_AGENDA_LOAD_THRESHOLDS = {
+  heavyReadyCount: 3,
+  heavyStaleCount: 4,
+  heavyCriticalCount: 3,
+  heavyPressuredCount: 6,
+  mediumReadyCount: 2,
+  mediumStaleCount: 2,
+  mediumCriticalCount: 1,
+  mediumPressuredFamilies: 3,
+} as const;
+
+export const HOOK_VISIBILITY_WINDOWS: Record<HookPayoffTiming, number> = {
+  immediate: 5,
+  "near-term": 5,
+  "mid-arc": 6,
+  "slow-burn": 8,
+  endgame: 10,
+};
+
+export const HOOK_RELEVANT_SELECTION_DEFAULTS = {
+  primary: {
+    baseLimit: 3,
+    pressuredExpansionLimit: 4,
+    pressuredThreshold: 4,
+  },
+  stale: {
+    defaultLimit: 1,
+    expandedLimit: 2,
+    overdueThreshold: 2,
+    familySpreadThreshold: 2,
+  },
+} as const;
+
+export const HOOK_HEALTH_DEFAULTS = {
+  maxActiveHooks: 12,
+  staleAfterChapters: 10,
+  noAdvanceWindow: 5,
+  newHookBurstThreshold: 2,
+} as const;
+
+export function resolveHookVisibilityWindow(timing: HookPayoffTiming): number {
+  return HOOK_VISIBILITY_WINDOWS[timing];
+}

+ 45 - 50
packages/core/src/utils/memory-retrieval.ts

@@ -16,6 +16,14 @@ import {
   resolveHookPayoffTiming,
   normalizeHookPayoffTiming,
 } from "./hook-lifecycle.js";
+import {
+  HOOK_AGENDA_LIMITS,
+  HOOK_AGENDA_LOAD_THRESHOLDS,
+  HOOK_PRESSURE_WEIGHTS,
+  HOOK_RELEVANT_SELECTION_DEFAULTS,
+  resolveHookVisibilityWindow,
+  type HookAgendaLoad,
+} from "./hook-policy.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
@@ -260,7 +268,7 @@ export function buildPlannerHookAgenda(params: {
     limit: resolveAgendaLimit({
       explicitLimit: params.maxStaleDebt,
       candidateCount: staleDebtCandidates.length,
-      fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].staleDebt,
+      fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt,
     }),
     forceInclude: (entry) => entry.lifecycle.overdue,
   }).map((entry) => entry.hook);
@@ -279,7 +287,7 @@ export function buildPlannerHookAgenda(params: {
     limit: resolveAgendaLimit({
       explicitLimit: params.maxMustAdvance,
       candidateCount: mustAdvanceCandidates.length,
-      fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance,
+      fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance,
     }),
     forceInclude: (entry) => entry.lifecycle.overdue,
   }).map((entry) => entry.hook);
@@ -296,15 +304,18 @@ export function buildPlannerHookAgenda(params: {
     limit: resolveAgendaLimit({
       explicitLimit: params.maxEligibleResolve,
       candidateCount: eligibleResolveCandidates.length,
-      fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve,
+      fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve,
     }),
-    forceInclude: (entry) => entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40,
+    forceInclude: (entry) => (
+      entry.lifecycle.overdue
+      || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure
+    ),
   }).map((entry) => entry.hook);
   const avoidNewHookFamilies = [...new Set([
     ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
-  ])].slice(0, ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies);
+  ])].slice(0, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies);
   const pressureMap = buildHookPressureMap({
     lifecycleEntries,
     mustAdvanceHooks,
@@ -321,34 +332,6 @@ export function buildPlannerHookAgenda(params: {
   };
 }
 
-type HookAgendaLoad = "light" | "medium" | "heavy";
-
-const ADAPTIVE_HOOK_AGENDA_LIMITS: Record<HookAgendaLoad, {
-  readonly staleDebt: number;
-  readonly mustAdvance: number;
-  readonly eligibleResolve: number;
-  readonly avoidFamilies: number;
-}> = {
-  light: {
-    staleDebt: 1,
-    mustAdvance: 2,
-    eligibleResolve: 1,
-    avoidFamilies: 2,
-  },
-  medium: {
-    staleDebt: 2,
-    mustAdvance: 2,
-    eligibleResolve: 1,
-    avoidFamilies: 3,
-  },
-  heavy: {
-    staleDebt: 3,
-    mustAdvance: 3,
-    eligibleResolve: 2,
-    avoidFamilies: 4,
-  },
-};
-
 function resolveHookAgendaLoad(entries: ReadonlyArray<{
   readonly hook: ReturnType<typeof normalizeStoredHook>;
   readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
@@ -361,16 +344,27 @@ function resolveHookAgendaLoad(entries: ReadonlyArray<{
   const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length;
   const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length;
   const criticalCount = pressuredEntries.filter((entry) =>
-    entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40,
+    entry.lifecycle.overdue
+    || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure,
   ).length;
   const pressuredFamilies = new Set(
     pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)),
   ).size;
 
-  if (readyCount >= 3 || staleCount >= 4 || criticalCount >= 3 || pressuredEntries.length >= 6) {
+  if (
+    readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount
+    || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount
+    || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount
+    || pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount
+  ) {
     return "heavy";
   }
-  if (readyCount >= 2 || staleCount >= 2 || criticalCount >= 1 || pressuredFamilies >= 3) {
+  if (
+    readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount
+    || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount
+    || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount
+    || pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies
+  ) {
     return "medium";
   }
   return "light";
@@ -469,7 +463,9 @@ function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{
     || entry.lifecycle.stale
     || entry.lifecycle.overdue,
   ).length;
-  return pressuredCount >= 4 ? 4 : 3;
+  return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold
+    ? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit
+    : HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit;
 }
 
 function resolveRelevantHookStaleLimit(
@@ -494,11 +490,14 @@ function resolveRelevantHookStaleLimit(
     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);
+  if (
+    overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold
+    || staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold
+  ) {
+    return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length);
   }
 
-  return 1;
+  return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit;
 }
 
 export function isHookWithinLifecycleWindow(
@@ -506,15 +505,11 @@ export function isHookWithinLifecycleWindow(
   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);
+  return isHookWithinChapterWindow(
+    hook,
+    chapterNumber,
+    resolveHookVisibilityWindow(lifecycle.timing),
+  );
 }
 
 function isMustAdvanceCandidate(
@@ -523,7 +518,7 @@ function isMustAdvanceCandidate(
   return lifecycle.stale
     || lifecycle.readyToResolve
     || lifecycle.overdue
-    || lifecycle.advancePressure >= 8;
+    || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor;
 }
 
 function buildHookPressureMap(params: {