@indexnetwork/protocol 4.9.0-rc.348.1 → 4.10.0-rc.350.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/index.d.ts +7 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/intent/intent.clarifier.d.ts +26 -5
  7. package/dist/intent/intent.clarifier.d.ts.map +1 -1
  8. package/dist/intent/intent.clarifier.js +59 -29
  9. package/dist/intent/intent.clarifier.js.map +1 -1
  10. package/dist/intent/intent.tools.d.ts +5 -0
  11. package/dist/intent/intent.tools.d.ts.map +1 -1
  12. package/dist/intent/intent.tools.js +44 -5
  13. package/dist/intent/intent.tools.js.map +1 -1
  14. package/dist/opportunity/discriminator/discriminator.adjustments.d.ts +52 -0
  15. package/dist/opportunity/discriminator/discriminator.adjustments.d.ts.map +1 -0
  16. package/dist/opportunity/discriminator/discriminator.adjustments.js +97 -0
  17. package/dist/opportunity/discriminator/discriminator.adjustments.js.map +1 -0
  18. package/dist/opportunity/discriminator/discriminator.env.d.ts +25 -0
  19. package/dist/opportunity/discriminator/discriminator.env.d.ts.map +1 -1
  20. package/dist/opportunity/discriminator/discriminator.env.js +25 -0
  21. package/dist/opportunity/discriminator/discriminator.env.js.map +1 -1
  22. package/dist/opportunity/discriminator/discriminator.question.d.ts.map +1 -1
  23. package/dist/opportunity/discriminator/discriminator.question.js +14 -5
  24. package/dist/opportunity/discriminator/discriminator.question.js.map +1 -1
  25. package/dist/opportunity/feed/feed.graph.d.ts.map +1 -1
  26. package/dist/opportunity/feed/feed.graph.js +56 -13
  27. package/dist/opportunity/feed/feed.graph.js.map +1 -1
  28. package/dist/opportunity/feed/feed.state.d.ts +2 -0
  29. package/dist/opportunity/feed/feed.state.d.ts.map +1 -1
  30. package/dist/opportunity/feed/feed.state.js.map +1 -1
  31. package/dist/opportunity/opportunity.presenter.d.ts +6 -0
  32. package/dist/opportunity/opportunity.presenter.d.ts.map +1 -1
  33. package/dist/opportunity/opportunity.presenter.js +12 -3
  34. package/dist/opportunity/opportunity.presenter.js.map +1 -1
  35. package/dist/opportunity/question.generator.d.ts.map +1 -1
  36. package/dist/opportunity/question.generator.js +9 -4
  37. package/dist/opportunity/question.generator.js.map +1 -1
  38. package/dist/questioner/questioner.agent.d.ts.map +1 -1
  39. package/dist/questioner/questioner.agent.js +4 -3
  40. package/dist/questioner/questioner.agent.js.map +1 -1
  41. package/dist/questioner/questioner.ask.tool.d.ts.map +1 -1
  42. package/dist/questioner/questioner.ask.tool.js +4 -0
  43. package/dist/questioner/questioner.ask.tool.js.map +1 -1
  44. package/dist/questioner/questioner.presets.d.ts.map +1 -1
  45. package/dist/questioner/questioner.presets.js +10 -6
  46. package/dist/questioner/questioner.presets.js.map +1 -1
  47. package/dist/questioner/questioner.qud.d.ts +8 -0
  48. package/dist/questioner/questioner.qud.d.ts.map +1 -0
  49. package/dist/questioner/questioner.qud.js +12 -0
  50. package/dist/questioner/questioner.qud.js.map +1 -0
  51. package/dist/shared/interfaces/questioner.interface.d.ts +3 -1
  52. package/dist/shared/interfaces/questioner.interface.d.ts.map +1 -1
  53. package/dist/shared/interfaces/questioner.interface.js.map +1 -1
  54. package/dist/shared/schemas/question.schema.d.ts +29 -7
  55. package/dist/shared/schemas/question.schema.d.ts.map +1 -1
  56. package/dist/shared/schemas/question.schema.js +16 -4
  57. package/dist/shared/schemas/question.schema.js.map +1 -1
  58. package/package.json +3 -2
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discriminator.adjustments.js","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.adjustments.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pool-adjustment domain logic (IND-419) — pure functions, no I/O.\n *\n * When a user answers a pool_discovery question, every candidate the mining\n * pass assigned to a side gets a multiplicative adjustment written to\n * `opportunities.metadata.poolAdjustments`. The home feed (flag-gated) orders\n * by `confidence × Π factor`, floored so demoted candidates stay visible.\n *\n * Invariants:\n * - Deterministic and O(pool): no LLM at answer time — the applyPlan was\n * computed (and evidence-verified) at mining time.\n * - Reversible: entries are keyed by questionId; re-answering replaces them,\n * dismissal-driven reversal removes them.\n * - `detail` carries the template chip text built from the user's OWN answer\n * (\"Hands-on builders vs advisors: you chose Advisor\") — never LLM\n * reasoning (opportunity-presentation-safety).\n */\nimport { POOL_ADJUSTMENT_FACTOR_OTHER, POOL_ADJUSTMENT_FLOOR } from \"./discriminator.env.js\";\nimport type { QuestionPoolDiscriminator } from \"../../shared/schemas/question.schema.js\";\n\n/** One applied adjustment on an opportunity (stored in metadata.poolAdjustments). */\nexport interface PoolAdjustment {\n /** The answered question that produced this adjustment (reversal key). */\n questionId: string;\n /** Discriminator label, e.g. \"Hands-on builders vs advisors\". */\n label: string;\n /** Side this candidate was assigned to (or \"unknown\"). */\n side: string;\n /** Multiplicative factor: chosen 1.0, other 0.6, unknown 0.9. */\n factor: number;\n /** Template chip text from the user's own answer. Set on demotions only. */\n detail?: string;\n /** ISO-8601 apply timestamp. */\n appliedAt: string;\n}\n\n/** Reads the adjustments array off an opportunity's metadata (tolerant). */\nexport function readPoolAdjustments(metadata: Record<string, unknown> | null | undefined): PoolAdjustment[] {\n const raw = metadata?.poolAdjustments;\n if (!Array.isArray(raw)) return [];\n return raw.filter((a): a is PoolAdjustment =>\n typeof a === \"object\" && a !== null &&\n typeof (a as PoolAdjustment).questionId === \"string\" &&\n typeof (a as PoolAdjustment).factor === \"number\");\n}\n\n/**\n * Cumulative adjustment multiplier for an opportunity, floored at\n * {@link POOL_ADJUSTMENT_FLOOR}. 1 when no adjustments exist.\n */\nexport function poolAdjustmentMultiplier(metadata: Record<string, unknown> | null | undefined): number {\n const adjustments = readPoolAdjustments(metadata);\n if (adjustments.length === 0) return 1;\n const product = adjustments.reduce((acc, a) => acc * (Number.isFinite(a.factor) && a.factor > 0 ? a.factor : 1), 1);\n return Math.max(POOL_ADJUSTMENT_FLOOR, product);\n}\n\n/** Adjusted confidence: `confidence × Π factor`, floored. */\nexport function adjustedConfidence(confidence: number, metadata: Record<string, unknown> | null | undefined): number {\n return confidence * poolAdjustmentMultiplier(metadata);\n}\n\n/** Plan entry: what to write on one opportunity for one answer. */\nexport interface PoolAdjustmentPlanEntry {\n opportunityId: string;\n adjustment: PoolAdjustment;\n}\n\n/**\n * Computes the write plan for one answered discriminator. Pure: the caller\n * loads/patches rows. \"Both matter\" (or any label not in `sides`) yields an\n * empty plan — no preference, no adjustments.\n *\n * @param discriminator The asked discriminator (from detection.pool).\n * @param chosenSide The selected option label (chip label = side label).\n * @param questionId Reversal key.\n * @param now ISO-8601 timestamp.\n */\nexport function planPoolAdjustments(\n discriminator: QuestionPoolDiscriminator,\n chosenSide: string,\n questionId: string,\n now: string,\n): PoolAdjustmentPlanEntry[] {\n // Chip labels are word-capped side labels; match on the capped prefix too.\n const matchesSide = (side: string): boolean =>\n side === chosenSide || side.startsWith(chosenSide) || chosenSide.startsWith(side);\n const chosen = discriminator.sides.find(matchesSide);\n if (!chosen) return []; // \"Both matter\" or unrecognized option → no adjustments.\n\n const plan: PoolAdjustmentPlanEntry[] = [];\n for (const a of discriminator.assignments) {\n const isChosen = a.side === chosen;\n plan.push({\n opportunityId: a.opportunityId,\n adjustment: {\n questionId,\n label: discriminator.label,\n side: a.side,\n factor: isChosen ? 1 : POOL_ADJUSTMENT_FACTOR_OTHER,\n ...(isChosen ? {} : { detail: `${discriminator.label}: you chose ${chosen}` }),\n appliedAt: now,\n },\n });\n }\n return plan;\n}\n\n/**\n * Merges one adjustment into an opportunity's existing metadata, replacing\n * any prior entry for the same questionId (re-answer semantics). Returns the\n * NEW metadata object (caller persists it wholesale).\n */\nexport function mergePoolAdjustment(\n metadata: Record<string, unknown> | null | undefined,\n adjustment: PoolAdjustment,\n): Record<string, unknown> {\n const existing = readPoolAdjustments(metadata).filter((a) => a.questionId !== adjustment.questionId);\n return { ...(metadata ?? {}), poolAdjustments: [...existing, adjustment] };\n}\n\n/** Latest user-explainable demotion detail for card presentation, if any. */\nexport function latestPoolDemotionDetail(\n metadata: Record<string, unknown> | null | undefined,\n): string | undefined {\n return [...readPoolAdjustments(metadata)]\n .reverse()\n .find((adjustment) => adjustment.factor < 1 && typeof adjustment.detail === 'string' && adjustment.detail.length > 0)\n ?.detail;\n}\n\n/** Removes all adjustments for a questionId (dismissal/withdrawal reversal). */\nexport function removePoolAdjustment(\n metadata: Record<string, unknown> | null | undefined,\n questionId: string,\n): Record<string, unknown> {\n const remaining = readPoolAdjustments(metadata).filter((a) => a.questionId !== questionId);\n return { ...(metadata ?? {}), poolAdjustments: remaining };\n}\n"]}
@@ -33,6 +33,31 @@ export declare const POOL_QUESTION_MIN_EVIDENCE_RATE = 0.6;
33
33
  export declare const POOL_QUESTION_MAX_DISCRIMINATORS = 3;
34
34
  /** Unattended budget: max pending questions of ANY mode per intent. */
35
35
  export declare const POOL_QUESTION_MAX_PENDING_PER_INTENT = 3;
36
+ /** Ranking mode for pool adjustments (IND-419). */
37
+ export type PoolQuestionsRankingMode = "off" | "on";
38
+ /**
39
+ * Current POOL_QUESTIONS_RANKING (default off). When "on", the home feed
40
+ * orders by adjusted confidence (confidence × Π poolAdjustments.factor,
41
+ * floor {@link POOL_ADJUSTMENT_FLOOR}). Adjustments are WRITTEN regardless of
42
+ * this flag — it only gates whether ordering reads them, so the apply path
43
+ * can ship first.
44
+ */
45
+ export declare function poolQuestionsRanking(): PoolQuestionsRankingMode;
46
+ /** Adjustment factor for candidates on the side the user did NOT choose. */
47
+ export declare const POOL_ADJUSTMENT_FACTOR_OTHER = 0.6;
48
+ /** Adjustment factor for candidates the miner could not assign (unknown). */
49
+ export declare const POOL_ADJUSTMENT_FACTOR_UNKNOWN = 0.9;
50
+ /** Floor for the cumulative adjustment multiplier — demoted, never hidden. */
51
+ export declare const POOL_ADJUSTMENT_FLOOR = 0.3;
52
+ /**
53
+ * Staleness guard (IND-419): when more than this fraction of a question's
54
+ * stored assignments point at opportunities that left the pool (expired /
55
+ * rejected / accepted / archived), skip the re-rank — the snapshot no longer
56
+ * describes the live pool.
57
+ */
58
+ export declare const POOL_STALENESS_THRESHOLD = 0.3;
59
+ /** Debounce window for answer-triggered re-discovery (Tier 1), per intent. */
60
+ export declare const POOL_RERUN_DEBOUNCE_MS = 60000;
36
61
  /**
37
62
  * k-anonymity floor: axes are only mined when the pool has at least this many
38
63
  * candidates, so no axis (or later, question option) can be traced back to a
@@ -1 +1 @@
1
- {"version":3,"file":"discriminator.env.d.ts","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,uBAAuB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEvD,wDAAwD;AACxD,wBAAgB,uBAAuB,IAAI,uBAAuB,CAEjE;AAED,uDAAuD;AACvD,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,IAAI,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,CAErD;AAED,uEAAuE;AACvE,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,mFAAmF;AACnF,eAAO,MAAM,+BAA+B,MAAM,CAAC;AAEnD,gFAAgF;AAChF,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,oCAAoC,IAAI,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAElD,6DAA6D;AAC7D,eAAO,MAAM,iCAAiC,KAAK,CAAC;AAEpD,qEAAqE;AACrE,eAAO,MAAM,2CAA2C,MAAM,CAAC"}
1
+ {"version":3,"file":"discriminator.env.d.ts","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,uBAAuB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEvD,wDAAwD;AACxD,wBAAgB,uBAAuB,IAAI,uBAAuB,CAEjE;AAED,uDAAuD;AACvD,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,IAAI,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,CAErD;AAED,uEAAuE;AACvE,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,mFAAmF;AACnF,eAAO,MAAM,+BAA+B,MAAM,CAAC;AAEnD,gFAAgF;AAChF,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,oCAAoC,IAAI,CAAC;AAEtD,mDAAmD;AACnD,MAAM,MAAM,wBAAwB,GAAG,KAAK,GAAG,IAAI,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,wBAAwB,CAE/D;AAED,4EAA4E;AAC5E,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD,6EAA6E;AAC7E,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAElD,8EAA8E;AAC9E,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAElD,6DAA6D;AAC7D,eAAO,MAAM,iCAAiC,KAAK,CAAC;AAEpD,qEAAqE;AACrE,eAAO,MAAM,2CAA2C,MAAM,CAAC"}
@@ -33,6 +33,31 @@ export const POOL_QUESTION_MIN_EVIDENCE_RATE = 0.6;
33
33
  export const POOL_QUESTION_MAX_DISCRIMINATORS = 3;
34
34
  /** Unattended budget: max pending questions of ANY mode per intent. */
35
35
  export const POOL_QUESTION_MAX_PENDING_PER_INTENT = 3;
36
+ /**
37
+ * Current POOL_QUESTIONS_RANKING (default off). When "on", the home feed
38
+ * orders by adjusted confidence (confidence × Π poolAdjustments.factor,
39
+ * floor {@link POOL_ADJUSTMENT_FLOOR}). Adjustments are WRITTEN regardless of
40
+ * this flag — it only gates whether ordering reads them, so the apply path
41
+ * can ship first.
42
+ */
43
+ export function poolQuestionsRanking() {
44
+ return process.env.POOL_QUESTIONS_RANKING?.trim() === "on" ? "on" : "off";
45
+ }
46
+ /** Adjustment factor for candidates on the side the user did NOT choose. */
47
+ export const POOL_ADJUSTMENT_FACTOR_OTHER = 0.6;
48
+ /** Adjustment factor for candidates the miner could not assign (unknown). */
49
+ export const POOL_ADJUSTMENT_FACTOR_UNKNOWN = 0.9;
50
+ /** Floor for the cumulative adjustment multiplier — demoted, never hidden. */
51
+ export const POOL_ADJUSTMENT_FLOOR = 0.3;
52
+ /**
53
+ * Staleness guard (IND-419): when more than this fraction of a question's
54
+ * stored assignments point at opportunities that left the pool (expired /
55
+ * rejected / accepted / archived), skip the re-rank — the snapshot no longer
56
+ * describes the live pool.
57
+ */
58
+ export const POOL_STALENESS_THRESHOLD = 0.3;
59
+ /** Debounce window for answer-triggered re-discovery (Tier 1), per intent. */
60
+ export const POOL_RERUN_DEBOUNCE_MS = 60000;
36
61
  /**
37
62
  * k-anonymity floor: axes are only mined when the pool has at least this many
38
63
  * candidates, so no axis (or later, question option) can be traced back to a
@@ -1 +1 @@
1
- {"version":3,"file":"discriminator.env.js","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,wDAAwD;AACxD,MAAM,UAAU,uBAAuB;IACrC,OAAO,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,IAAI,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC;AACnF,CAAC;AAKD;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC;AACzE,CAAC;AAED,uEAAuE;AACvE,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAEzC,mFAAmF;AACnF,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAG,CAAC;AAEnD,gFAAgF;AAChF,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC;AAElD,uEAAuE;AACvE,MAAM,CAAC,MAAM,oCAAoC,GAAG,CAAC,CAAC;AAEtD;;;;GAIG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC;AAElD,6DAA6D;AAC7D,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAC;AAEpD,qEAAqE;AACrE,MAAM,CAAC,MAAM,2CAA2C,GAAG,GAAG,CAAC","sourcesContent":["/**\n * Centralized accessors for pool-question environment variables (IND-416).\n *\n * POOL_QUESTIONS_MINING off | shadow — P1 shadow axis mining on\n * discovery-run completion. `shadow` mines and\n * scores axes and logs them; no questions are\n * generated and no user-facing behavior changes.\n * Any value other than \"shadow\" (including unset)\n * means off.\n *\n * All reads go through this module — do not read these variables via\n * `process.env` elsewhere. Values are read on every call (no caching) so\n * tests and long-lived processes observe changes.\n */\n\n/** Mining mode for pool discriminator axes. */\nexport type PoolQuestionsMiningMode = \"off\" | \"shadow\";\n\n/** Current POOL_QUESTIONS_MINING mode (default off). */\nexport function poolQuestionsMiningMode(): PoolQuestionsMiningMode {\n return process.env.POOL_QUESTIONS_MINING?.trim() === \"shadow\" ? \"shadow\" : \"off\";\n}\n\n/** Question mode for pool discriminators (IND-418). */\nexport type PoolQuestionsMode = \"off\" | \"on\";\n\n/**\n * Current POOL_QUESTIONS_MODE (default off). When \"on\", the mining hook also\n * enqueues a pool_discovery question for the top eligible discriminator\n * (still subject to the QUESTIONER_ENABLED master gate and per-intent budget).\n * \"on\" implies mining runs even when POOL_QUESTIONS_MINING is off.\n */\nexport function poolQuestionsMode(): PoolQuestionsMode {\n return process.env.POOL_QUESTIONS_MODE?.trim() === \"on\" ? \"on\" : \"off\";\n}\n\n/** Minimum VoI for a discriminator to become (or chain) a question. */\nexport const POOL_QUESTION_MIN_VOI = 0.2;\n\n/** Minimum evidence-verification rate for a discriminator to become a question. */\nexport const POOL_QUESTION_MIN_EVIDENCE_RATE = 0.6;\n\n/** Max eligible discriminators carried per mining pass (asked + alternates). */\nexport const POOL_QUESTION_MAX_DISCRIMINATORS = 3;\n\n/** Unattended budget: max pending questions of ANY mode per intent. */\nexport const POOL_QUESTION_MAX_PENDING_PER_INTENT = 3;\n\n/**\n * k-anonymity floor: axes are only mined when the pool has at least this many\n * candidates, so no axis (or later, question option) can be traced back to a\n * specific individual.\n */\nexport const POOL_DISCRIMINATOR_MIN_POOL_SIZE = 5;\n\n/** Max candidates sent to the miner LLM (top-N by score). */\nexport const POOL_DISCRIMINATOR_MAX_CANDIDATES = 24;\n\n/** Max chars of public context per candidate in the miner prompt. */\nexport const POOL_DISCRIMINATOR_MAX_PUBLIC_CONTEXT_CHARS = 400;\n"]}
1
+ {"version":3,"file":"discriminator.env.js","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,wDAAwD;AACxD,MAAM,UAAU,uBAAuB;IACrC,OAAO,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,IAAI,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC;AACnF,CAAC;AAKD;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC;AACzE,CAAC;AAED,uEAAuE;AACvE,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAEzC,mFAAmF;AACnF,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAG,CAAC;AAEnD,gFAAgF;AAChF,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC;AAElD,uEAAuE;AACvE,MAAM,CAAC,MAAM,oCAAoC,GAAG,CAAC,CAAC;AAKtD;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,OAAO,CAAC,GAAG,CAAC,sBAAsB,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5E,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,MAAM,4BAA4B,GAAG,GAAG,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,8BAA8B,GAAG,GAAG,CAAC;AAElD,8EAA8E;AAC9E,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAEzC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAE5C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAM,CAAC;AAE7C;;;;GAIG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC;AAElD,6DAA6D;AAC7D,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAC;AAEpD,qEAAqE;AACrE,MAAM,CAAC,MAAM,2CAA2C,GAAG,GAAG,CAAC","sourcesContent":["/**\n * Centralized accessors for pool-question environment variables (IND-416).\n *\n * POOL_QUESTIONS_MINING off | shadow — P1 shadow axis mining on\n * discovery-run completion. `shadow` mines and\n * scores axes and logs them; no questions are\n * generated and no user-facing behavior changes.\n * Any value other than \"shadow\" (including unset)\n * means off.\n *\n * All reads go through this module — do not read these variables via\n * `process.env` elsewhere. Values are read on every call (no caching) so\n * tests and long-lived processes observe changes.\n */\n\n/** Mining mode for pool discriminator axes. */\nexport type PoolQuestionsMiningMode = \"off\" | \"shadow\";\n\n/** Current POOL_QUESTIONS_MINING mode (default off). */\nexport function poolQuestionsMiningMode(): PoolQuestionsMiningMode {\n return process.env.POOL_QUESTIONS_MINING?.trim() === \"shadow\" ? \"shadow\" : \"off\";\n}\n\n/** Question mode for pool discriminators (IND-418). */\nexport type PoolQuestionsMode = \"off\" | \"on\";\n\n/**\n * Current POOL_QUESTIONS_MODE (default off). When \"on\", the mining hook also\n * enqueues a pool_discovery question for the top eligible discriminator\n * (still subject to the QUESTIONER_ENABLED master gate and per-intent budget).\n * \"on\" implies mining runs even when POOL_QUESTIONS_MINING is off.\n */\nexport function poolQuestionsMode(): PoolQuestionsMode {\n return process.env.POOL_QUESTIONS_MODE?.trim() === \"on\" ? \"on\" : \"off\";\n}\n\n/** Minimum VoI for a discriminator to become (or chain) a question. */\nexport const POOL_QUESTION_MIN_VOI = 0.2;\n\n/** Minimum evidence-verification rate for a discriminator to become a question. */\nexport const POOL_QUESTION_MIN_EVIDENCE_RATE = 0.6;\n\n/** Max eligible discriminators carried per mining pass (asked + alternates). */\nexport const POOL_QUESTION_MAX_DISCRIMINATORS = 3;\n\n/** Unattended budget: max pending questions of ANY mode per intent. */\nexport const POOL_QUESTION_MAX_PENDING_PER_INTENT = 3;\n\n/** Ranking mode for pool adjustments (IND-419). */\nexport type PoolQuestionsRankingMode = \"off\" | \"on\";\n\n/**\n * Current POOL_QUESTIONS_RANKING (default off). When \"on\", the home feed\n * orders by adjusted confidence (confidence × Π poolAdjustments.factor,\n * floor {@link POOL_ADJUSTMENT_FLOOR}). Adjustments are WRITTEN regardless of\n * this flag — it only gates whether ordering reads them, so the apply path\n * can ship first.\n */\nexport function poolQuestionsRanking(): PoolQuestionsRankingMode {\n return process.env.POOL_QUESTIONS_RANKING?.trim() === \"on\" ? \"on\" : \"off\";\n}\n\n/** Adjustment factor for candidates on the side the user did NOT choose. */\nexport const POOL_ADJUSTMENT_FACTOR_OTHER = 0.6;\n\n/** Adjustment factor for candidates the miner could not assign (unknown). */\nexport const POOL_ADJUSTMENT_FACTOR_UNKNOWN = 0.9;\n\n/** Floor for the cumulative adjustment multiplier — demoted, never hidden. */\nexport const POOL_ADJUSTMENT_FLOOR = 0.3;\n\n/**\n * Staleness guard (IND-419): when more than this fraction of a question's\n * stored assignments point at opportunities that left the pool (expired /\n * rejected / accepted / archived), skip the re-rank — the snapshot no longer\n * describes the live pool.\n */\nexport const POOL_STALENESS_THRESHOLD = 0.3;\n\n/** Debounce window for answer-triggered re-discovery (Tier 1), per intent. */\nexport const POOL_RERUN_DEBOUNCE_MS = 60_000;\n\n/**\n * k-anonymity floor: axes are only mined when the pool has at least this many\n * candidates, so no axis (or later, question option) can be traced back to a\n * specific individual.\n */\nexport const POOL_DISCRIMINATOR_MIN_POOL_SIZE = 5;\n\n/** Max candidates sent to the miner LLM (top-N by score). */\nexport const POOL_DISCRIMINATOR_MAX_CANDIDATES = 24;\n\n/** Max chars of public context per candidate in the miner prompt. */\nexport const POOL_DISCRIMINATOR_MAX_PUBLIC_CONTEXT_CHARS = 400;\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"discriminator.question.d.ts","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.question.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAkB,yBAAyB,EAAE,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAEzI,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAEpE,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAQ/C;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,mBAAmB,GAAG,yBAAyB,CAmBzF;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,cAAc,EAAE,mBAAmB,EAAE,GAAG,mBAAmB,EAAE,CAIzG;AAwDD,wCAAwC;AACxC,MAAM,WAAW,2BAA2B;IAC1C,qEAAqE;IACrE,aAAa,EAAE,yBAAyB,CAAC;IACzC,6EAA6E;IAC7E,UAAU,EAAE,yBAAyB,EAAE,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mEAAmE;AACnE,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,QAAQ,CAAC;IAClB,IAAI,EAAE,oBAAoB,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,2BAA2B,GAAG,uBAAuB,GAAG,IAAI,CAyCzG"}
1
+ {"version":3,"file":"discriminator.question.d.ts","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.question.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAkB,yBAAyB,EAAE,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAEzI,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAEpE,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAQ/C;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,mBAAmB,GAAG,yBAAyB,CAmBzF;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,cAAc,EAAE,mBAAmB,EAAE,GAAG,mBAAmB,EAAE,CAIzG;AAkED,wCAAwC;AACxC,MAAM,WAAW,2BAA2B;IAC1C,qEAAqE;IACrE,aAAa,EAAE,yBAAyB,CAAC;IACzC,6EAA6E;IAC7E,UAAU,EAAE,yBAAyB,EAAE,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mEAAmE;AACnE,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,QAAQ,CAAC;IAClB,IAAI,EAAE,oBAAoB,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,2BAA2B,GAAG,uBAAuB,GAAG,IAAI,CAyCzG"}
@@ -61,10 +61,19 @@ function toSecondPerson(seed) {
61
61
  .replace(/\bmy\b/gi, "your")
62
62
  // Third-person seeds ("Is the user primarily involved…") — the question is
63
63
  // asked TO the owner, never ABOUT them.
64
- .replace(/\b(is|was) the (user|owner|discoverer)\b/gi, "are you")
65
- .replace(/\bdoes the (user|owner|discoverer)\b/gi, "do you")
66
- .replace(/\bthe (user|owner|discoverer)'s\b/gi, "your")
67
- .replace(/\bthe (user|owner|discoverer)\b/gi, "you");
64
+ .replace(/\b(is|was) (?:the|this) (user|owner|discoverer|client)\b/gi, "are you")
65
+ .replace(/\bdoes (?:the|this) (user|owner|discoverer|client)\b/gi, "do you")
66
+ .replace(/\b(?:the|this) (user|owner|discoverer|client)'s\b/gi, "your")
67
+ .replace(/\b(?:the|this) (user|owner|discoverer|client)\b/gi, "you");
68
+ }
69
+ /**
70
+ * Catch-all: after normalization the prompt must be second-person. Any
71
+ * residual third-person reference to the owner means an unanticipated seed
72
+ * shape slipped through — the deterministic two-sided template (always
73
+ * second-person) is the safe fallback.
74
+ */
75
+ function isStillThirdPerson(prompt) {
76
+ return /\b(?:the|this|a) (?:user|owner|discoverer|client)\b/i.test(prompt);
68
77
  }
69
78
  /**
70
79
  * A good discriminator prompt names BOTH sides so the chips read as a real
@@ -83,7 +92,7 @@ function mentionsSide(seed, side) {
83
92
  function toPrompt(questionSeed, sides) {
84
93
  let p = toSecondPerson(questionSeed.trim().replace(/\s+/g, " "));
85
94
  const sidesMentioned = sides.filter((s) => mentionsSide(p, s)).length;
86
- if (sidesMentioned < 2) {
95
+ if (sidesMentioned < 2 || isStillThirdPerson(p)) {
87
96
  const last = sides[sides.length - 1];
88
97
  const head = sides.slice(0, -1).join(", ");
89
98
  p = `Which matters more here: ${head} or ${last}`;
@@ -1 +1 @@
1
- {"version":3,"file":"discriminator.question.js","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.question.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Deterministic pool_discovery question synthesis (IND-418).\n *\n * A mined discriminator already carries everything a question needs: a label,\n * a question seed, 2–3 sides, and verified per-candidate assignments. This\n * module turns the top discriminator into a `Question` payload + server-side\n * pool snapshot with ZERO generation-time LLM calls — the phrasing was\n * produced (and evidence-verified) at mining time, so synthesis is pure,\n * auditable, and cheap to re-run for interview-mode chaining.\n */\nimport type { Question, QuestionOption, QuestionPoolDiscriminator, QuestionPoolSnapshot } from \"../../shared/schemas/question.schema.js\";\nimport { POOL_DISCRIMINATOR_MIN_POOL_SIZE, POOL_QUESTION_MAX_DISCRIMINATORS, POOL_QUESTION_MIN_EVIDENCE_RATE, POOL_QUESTION_MIN_VOI } from \"./discriminator.env.js\";\nimport type { ScoredDiscriminator } from \"./discriminator.types.js\";\n\n/** Fixed option appended to every pool question — the no-preference escape. */\nexport const BOTH_MATTER_LABEL = \"Both matter\";\n\n/** Max words in a chip-style option label (prototype voice: terse, glanceable). */\nconst MAX_LABEL_WORDS = 5;\n\n/** Question title (≤12 chars, per QuestionSchema). */\nconst POOL_QUESTION_TITLE = \"Matches\";\n\n/**\n * Converts a scored discriminator into the compact snapshot form stored on\n * the question row (verified assignments only; unknowns dropped).\n */\nexport function toQuestionDiscriminator(d: ScoredDiscriminator): QuestionPoolDiscriminator {\n const sideCounts: Record<string, number> = {};\n for (const side of d.sides) sideCounts[side] = 0;\n const assignments: Array<{ opportunityId: string; side: string }> = [];\n for (const a of d.assignments) {\n if (a.side === null || !a.verified) continue;\n if (!(a.side in sideCounts)) continue;\n sideCounts[a.side] += 1;\n assignments.push({ opportunityId: a.id, side: a.side });\n }\n return {\n label: d.label,\n questionSeed: d.questionSeed,\n sides: d.sides,\n sideCounts,\n voi: d.voi,\n evidenceRate: d.evidenceRate,\n assignments,\n };\n}\n\n/**\n * Filters a mining pass down to question-eligible discriminators:\n * VoI ≥ {@link POOL_QUESTION_MIN_VOI}, evidenceRate ≥\n * {@link POOL_QUESTION_MIN_EVIDENCE_RATE}, capped at\n * {@link POOL_QUESTION_MAX_DISCRIMINATORS}. Input is already VoI-sorted.\n */\nexport function selectQuestionDiscriminators(discriminators: ScoredDiscriminator[]): ScoredDiscriminator[] {\n return discriminators\n .filter((d) => d.voi >= POOL_QUESTION_MIN_VOI && d.evidenceRate >= POOL_QUESTION_MIN_EVIDENCE_RATE)\n .slice(0, POOL_QUESTION_MAX_DISCRIMINATORS);\n}\n\n/** Truncates a side label to the chip word budget. */\nfunction chipLabel(side: string): string {\n const words = side.trim().split(/\\s+/);\n return words.slice(0, MAX_LABEL_WORDS).join(\" \");\n}\n\n/**\n * Miner seeds are sometimes written from the intent owner's POV (\"Do I\n * prefer…\"). The question is asked TO the owner, so normalize to second\n * person. Conservative verb-pair map + bare pronoun/possessive swaps.\n */\nfunction toSecondPerson(seed: string): string {\n return seed\n .replace(/\\b(do|should|would|could|can|will) I\\b/gi, (_, verb: string) => `${verb.toLowerCase()} you`)\n .replace(/\\bam I\\b/gi, \"are you\")\n .replace(/\\bI\\b/g, \"you\")\n .replace(/\\bmy\\b/gi, \"your\")\n // Third-person seeds (\"Is the user primarily involved…\") — the question is\n // asked TO the owner, never ABOUT them.\n .replace(/\\b(is|was) the (user|owner|discoverer)\\b/gi, \"are you\")\n .replace(/\\bdoes the (user|owner|discoverer)\\b/gi, \"do you\")\n .replace(/\\bthe (user|owner|discoverer)'s\\b/gi, \"your\")\n .replace(/\\bthe (user|owner|discoverer)\\b/gi, \"you\");\n}\n\n/**\n * A good discriminator prompt names BOTH sides so the chips read as a real\n * choice. When the seed mentions fewer than two sides (e.g. \"Do you prefer\n * hands-on prototyping?\"), fall back to a deterministic two-sided template.\n */\nfunction mentionsSide(seed: string, side: string): boolean {\n const lowerSeed = seed.toLowerCase();\n return side\n .toLowerCase()\n .split(/[^a-z0-9-]+/)\n .filter((w) => w.length > 3)\n .some((w) => lowerSeed.includes(w));\n}\n\n/** Ensures the prompt reads as one second-person, two-sided question. */\nfunction toPrompt(questionSeed: string, sides: string[]): string {\n let p = toSecondPerson(questionSeed.trim().replace(/\\s+/g, \" \"));\n const sidesMentioned = sides.filter((s) => mentionsSide(p, s)).length;\n if (sidesMentioned < 2) {\n const last = sides[sides.length - 1];\n const head = sides.slice(0, -1).join(\", \");\n p = `Which matters more here: ${head} or ${last}`;\n }\n // Sentence-case the first character (template + normalization can lowercase it).\n p = p.charAt(0).toUpperCase() + p.slice(1);\n if (!/[?]$/.test(p)) p = p.replace(/[.!]+$/, \"\") + \"?\";\n return p.slice(0, 400);\n}\n\n/** Input for one question synthesis. */\nexport interface SynthesizePoolQuestionInput {\n /** The discriminator to ask about (already eligibility-filtered). */\n discriminator: QuestionPoolDiscriminator;\n /** Remaining ranked eligible discriminators (interview-mode chain stash). */\n alternates: QuestionPoolDiscriminator[];\n poolSize: number;\n /** ISO-8601 timestamp of the mining pass. */\n minedAt: string;\n /** Discovery run id, when known. */\n runId?: string;\n /**\n * Intent payload snippet — folded into the evidence chip so the question\n * self-identifies on any surface (\"based on 16 people matching ‘…’\").\n */\n intentText?: string;\n}\n\n/** Synthesized question: client payload + server-side snapshot. */\nexport interface SynthesizedPoolQuestion {\n payload: Question;\n pool: QuestionPoolSnapshot;\n}\n\n/**\n * Builds the pool_discovery question. Returns null when the pool is below\n * the k-anonymity floor or the discriminator shape is unusable (guards are\n * duplicated here so no caller can bypass them).\n */\nexport function synthesizePoolQuestion(input: SynthesizePoolQuestionInput): SynthesizedPoolQuestion | null {\n const { discriminator: d, poolSize } = input;\n if (poolSize < POOL_DISCRIMINATOR_MIN_POOL_SIZE) return null;\n if (d.sides.length < 2 || d.sides.length > 3) return null;\n\n // Evidence chip: aggregate count + the intent it belongs to, so the card is\n // self-identifying on every surface (QuestionSchema caps evidence at 160).\n const snippet = input.intentText?.trim().replace(/\\s+/g, \" \") ?? \"\";\n const evidence = snippet.length > 0\n ? `based on ${poolSize} people matching “${snippet.slice(0, 110)}${snippet.length > 110 ? \"…\" : \"\"}”`\n : `based on ${poolSize} people matching this intent`;\n\n const sideOptions: QuestionOption[] = d.sides.map((side) => ({\n label: chipLabel(side),\n description: `${d.sideCounts[side] ?? 0} of your ${poolSize} current matches lean this way`,\n }));\n const options: QuestionOption[] = [\n ...sideOptions,\n {\n label: BOTH_MATTER_LABEL,\n description: \"No preference — keep the current ranking\",\n },\n ];\n\n return {\n payload: {\n title: POOL_QUESTION_TITLE,\n prompt: toPrompt(d.questionSeed, d.sides),\n options,\n multiSelect: false,\n evidence,\n },\n pool: {\n poolSize,\n minedAt: input.minedAt,\n ...(input.runId ? { runId: input.runId } : {}),\n ...(snippet.length > 0 ? { intentText: snippet.slice(0, 160) } : {}),\n discriminator: d,\n alternates: input.alternates,\n },\n };\n}\n"]}
1
+ {"version":3,"file":"discriminator.question.js","sourceRoot":"/","sources":["opportunity/discriminator/discriminator.question.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Deterministic pool_discovery question synthesis (IND-418).\n *\n * A mined discriminator already carries everything a question needs: a label,\n * a question seed, 2–3 sides, and verified per-candidate assignments. This\n * module turns the top discriminator into a `Question` payload + server-side\n * pool snapshot with ZERO generation-time LLM calls — the phrasing was\n * produced (and evidence-verified) at mining time, so synthesis is pure,\n * auditable, and cheap to re-run for interview-mode chaining.\n */\nimport type { Question, QuestionOption, QuestionPoolDiscriminator, QuestionPoolSnapshot } from \"../../shared/schemas/question.schema.js\";\nimport { POOL_DISCRIMINATOR_MIN_POOL_SIZE, POOL_QUESTION_MAX_DISCRIMINATORS, POOL_QUESTION_MIN_EVIDENCE_RATE, POOL_QUESTION_MIN_VOI } from \"./discriminator.env.js\";\nimport type { ScoredDiscriminator } from \"./discriminator.types.js\";\n\n/** Fixed option appended to every pool question — the no-preference escape. */\nexport const BOTH_MATTER_LABEL = \"Both matter\";\n\n/** Max words in a chip-style option label (prototype voice: terse, glanceable). */\nconst MAX_LABEL_WORDS = 5;\n\n/** Question title (≤12 chars, per QuestionSchema). */\nconst POOL_QUESTION_TITLE = \"Matches\";\n\n/**\n * Converts a scored discriminator into the compact snapshot form stored on\n * the question row (verified assignments only; unknowns dropped).\n */\nexport function toQuestionDiscriminator(d: ScoredDiscriminator): QuestionPoolDiscriminator {\n const sideCounts: Record<string, number> = {};\n for (const side of d.sides) sideCounts[side] = 0;\n const assignments: Array<{ opportunityId: string; side: string }> = [];\n for (const a of d.assignments) {\n if (a.side === null || !a.verified) continue;\n if (!(a.side in sideCounts)) continue;\n sideCounts[a.side] += 1;\n assignments.push({ opportunityId: a.id, side: a.side });\n }\n return {\n label: d.label,\n questionSeed: d.questionSeed,\n sides: d.sides,\n sideCounts,\n voi: d.voi,\n evidenceRate: d.evidenceRate,\n assignments,\n };\n}\n\n/**\n * Filters a mining pass down to question-eligible discriminators:\n * VoI ≥ {@link POOL_QUESTION_MIN_VOI}, evidenceRate ≥\n * {@link POOL_QUESTION_MIN_EVIDENCE_RATE}, capped at\n * {@link POOL_QUESTION_MAX_DISCRIMINATORS}. Input is already VoI-sorted.\n */\nexport function selectQuestionDiscriminators(discriminators: ScoredDiscriminator[]): ScoredDiscriminator[] {\n return discriminators\n .filter((d) => d.voi >= POOL_QUESTION_MIN_VOI && d.evidenceRate >= POOL_QUESTION_MIN_EVIDENCE_RATE)\n .slice(0, POOL_QUESTION_MAX_DISCRIMINATORS);\n}\n\n/** Truncates a side label to the chip word budget. */\nfunction chipLabel(side: string): string {\n const words = side.trim().split(/\\s+/);\n return words.slice(0, MAX_LABEL_WORDS).join(\" \");\n}\n\n/**\n * Miner seeds are sometimes written from the intent owner's POV (\"Do I\n * prefer…\"). The question is asked TO the owner, so normalize to second\n * person. Conservative verb-pair map + bare pronoun/possessive swaps.\n */\nfunction toSecondPerson(seed: string): string {\n return seed\n .replace(/\\b(do|should|would|could|can|will) I\\b/gi, (_, verb: string) => `${verb.toLowerCase()} you`)\n .replace(/\\bam I\\b/gi, \"are you\")\n .replace(/\\bI\\b/g, \"you\")\n .replace(/\\bmy\\b/gi, \"your\")\n // Third-person seeds (\"Is the user primarily involved…\") — the question is\n // asked TO the owner, never ABOUT them.\n .replace(/\\b(is|was) (?:the|this) (user|owner|discoverer|client)\\b/gi, \"are you\")\n .replace(/\\bdoes (?:the|this) (user|owner|discoverer|client)\\b/gi, \"do you\")\n .replace(/\\b(?:the|this) (user|owner|discoverer|client)'s\\b/gi, \"your\")\n .replace(/\\b(?:the|this) (user|owner|discoverer|client)\\b/gi, \"you\");\n}\n\n/**\n * Catch-all: after normalization the prompt must be second-person. Any\n * residual third-person reference to the owner means an unanticipated seed\n * shape slipped through — the deterministic two-sided template (always\n * second-person) is the safe fallback.\n */\nfunction isStillThirdPerson(prompt: string): boolean {\n return /\\b(?:the|this|a) (?:user|owner|discoverer|client)\\b/i.test(prompt);\n}\n\n/**\n * A good discriminator prompt names BOTH sides so the chips read as a real\n * choice. When the seed mentions fewer than two sides (e.g. \"Do you prefer\n * hands-on prototyping?\"), fall back to a deterministic two-sided template.\n */\nfunction mentionsSide(seed: string, side: string): boolean {\n const lowerSeed = seed.toLowerCase();\n return side\n .toLowerCase()\n .split(/[^a-z0-9-]+/)\n .filter((w) => w.length > 3)\n .some((w) => lowerSeed.includes(w));\n}\n\n/** Ensures the prompt reads as one second-person, two-sided question. */\nfunction toPrompt(questionSeed: string, sides: string[]): string {\n let p = toSecondPerson(questionSeed.trim().replace(/\\s+/g, \" \"));\n const sidesMentioned = sides.filter((s) => mentionsSide(p, s)).length;\n if (sidesMentioned < 2 || isStillThirdPerson(p)) {\n const last = sides[sides.length - 1];\n const head = sides.slice(0, -1).join(\", \");\n p = `Which matters more here: ${head} or ${last}`;\n }\n // Sentence-case the first character (template + normalization can lowercase it).\n p = p.charAt(0).toUpperCase() + p.slice(1);\n if (!/[?]$/.test(p)) p = p.replace(/[.!]+$/, \"\") + \"?\";\n return p.slice(0, 400);\n}\n\n/** Input for one question synthesis. */\nexport interface SynthesizePoolQuestionInput {\n /** The discriminator to ask about (already eligibility-filtered). */\n discriminator: QuestionPoolDiscriminator;\n /** Remaining ranked eligible discriminators (interview-mode chain stash). */\n alternates: QuestionPoolDiscriminator[];\n poolSize: number;\n /** ISO-8601 timestamp of the mining pass. */\n minedAt: string;\n /** Discovery run id, when known. */\n runId?: string;\n /**\n * Intent payload snippet — folded into the evidence chip so the question\n * self-identifies on any surface (\"based on 16 people matching ‘…’\").\n */\n intentText?: string;\n}\n\n/** Synthesized question: client payload + server-side snapshot. */\nexport interface SynthesizedPoolQuestion {\n payload: Question;\n pool: QuestionPoolSnapshot;\n}\n\n/**\n * Builds the pool_discovery question. Returns null when the pool is below\n * the k-anonymity floor or the discriminator shape is unusable (guards are\n * duplicated here so no caller can bypass them).\n */\nexport function synthesizePoolQuestion(input: SynthesizePoolQuestionInput): SynthesizedPoolQuestion | null {\n const { discriminator: d, poolSize } = input;\n if (poolSize < POOL_DISCRIMINATOR_MIN_POOL_SIZE) return null;\n if (d.sides.length < 2 || d.sides.length > 3) return null;\n\n // Evidence chip: aggregate count + the intent it belongs to, so the card is\n // self-identifying on every surface (QuestionSchema caps evidence at 160).\n const snippet = input.intentText?.trim().replace(/\\s+/g, \" \") ?? \"\";\n const evidence = snippet.length > 0\n ? `based on ${poolSize} people matching “${snippet.slice(0, 110)}${snippet.length > 110 ? \"…\" : \"\"}”`\n : `based on ${poolSize} people matching this intent`;\n\n const sideOptions: QuestionOption[] = d.sides.map((side) => ({\n label: chipLabel(side),\n description: `${d.sideCounts[side] ?? 0} of your ${poolSize} current matches lean this way`,\n }));\n const options: QuestionOption[] = [\n ...sideOptions,\n {\n label: BOTH_MATTER_LABEL,\n description: \"No preference — keep the current ranking\",\n },\n ];\n\n return {\n payload: {\n title: POOL_QUESTION_TITLE,\n prompt: toPrompt(d.questionSeed, d.sides),\n options,\n multiSelect: false,\n evidence,\n },\n pool: {\n poolSize,\n minedAt: input.minedAt,\n ...(input.runId ? { runId: input.runId } : {}),\n ...(snippet.length > 0 ? { intentText: snippet.slice(0, 160) } : {}),\n discriminator: d,\n alternates: input.alternates,\n },\n };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"feed.graph.d.ts","sourceRoot":"/","sources":["opportunity/feed/feed.graph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AAC1G,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4CAA4C,CAAC;AACnF,OAAO,EAAkB,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,mBAAmB,EAAwB,MAAM,iBAAiB,CAAC;AAQtI,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAczE,8HAA8H;AAC9H,KAAK,WAAW,GAAG,iBAAiB,CAAC;AAErC,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,QAAQ,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,IAAI,EAAE;QAAE,kBAAkB,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,qBAAqB,EAAE,iBAAiB,EAA0B,CAAC;AAgBhF,0HAA0H;AAC1H,eAAO,MAAM,wBAAwB,EAAE,iBAAiB,EAEhC,CAAC;AAczB;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAarF;AA4ED,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ;IAAe,OAAO,CAAC,KAAK;gBAApC,QAAQ,EAAE,WAAW,EAAU,KAAK,EAAE,gBAAgB;IAE1E,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0oBZ"}
1
+ {"version":3,"file":"feed.graph.d.ts","sourceRoot":"/","sources":["opportunity/feed/feed.graph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AAC1G,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4CAA4C,CAAC;AACnF,OAAO,EAAkB,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,mBAAmB,EAAwB,MAAM,iBAAiB,CAAC;AAQtI,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAgBzE,8HAA8H;AAC9H,KAAK,WAAW,GAAG,iBAAiB,CAAC;AAErC,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,QAAQ,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,IAAI,EAAE;QAAE,kBAAkB,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,qBAAqB,EAAE,iBAAiB,EAA0B,CAAC;AAgBhF,0HAA0H;AAC1H,eAAO,MAAM,wBAAwB,EAAE,iBAAiB,EAEhC,CAAC;AAczB;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAarF;AAwFD,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ;IAAe,OAAO,CAAC,KAAK;gBAApC,QAAQ,EAAE,WAAW,EAAU,KAAK,EAAE,gBAAgB;IAE1E,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuqBZ"}
@@ -21,6 +21,8 @@ import { safeFallbackSummary } from '../opportunity.safe-presentation.js';
21
21
  import { protocolLogger } from '../../shared/observability/protocol.logger.js';
22
22
  import { timed } from '../../shared/observability/performance.js';
23
23
  import { requestContext } from "../../shared/observability/request-context.js";
24
+ import { adjustedConfidence, latestPoolDemotionDetail } from '../discriminator/discriminator.adjustments.js';
25
+ import { poolQuestionsRanking } from '../discriminator/discriminator.env.js';
24
26
  const logger = protocolLogger('HomeGraph');
25
27
  const checkCategorizerCacheLog = protocolLogger('HomeGraph:checkCategorizerCache');
26
28
  const cacheCategorizerResultsLog = protocolLogger('HomeGraph:cacheCategorizerResults');
@@ -95,7 +97,7 @@ const safeParseDate = (value) => {
95
97
  return 0;
96
98
  };
97
99
  /** Confidence score for sorting (interpretation.confidence or opportunity.confidence). */
98
- const getConfidence = (opp) => {
100
+ const getRawConfidence = (opp) => {
99
101
  const fromInterp = opp.interpretation?.confidence;
100
102
  if (typeof fromInterp === 'number' && !Number.isNaN(fromInterp))
101
103
  return fromInterp;
@@ -114,6 +116,18 @@ const getConfidence = (opp) => {
114
116
  }
115
117
  return 0;
116
118
  };
119
+ /**
120
+ * Sort confidence, optionally pool-adjusted (IND-419): when
121
+ * POOL_QUESTIONS_RANKING=on, answered discriminators multiply confidence by
122
+ * their stored factors (floor 0.3) so the user's answers re-rank the feed.
123
+ * Flag off → identical to raw confidence (adjustments are write-only).
124
+ */
125
+ const getConfidence = (opp) => {
126
+ const raw = getRawConfidence(opp);
127
+ if (poolQuestionsRanking() !== 'on')
128
+ return raw;
129
+ return adjustedConfidence(raw, opp.metadata);
130
+ };
117
131
  /** Unique non-introducer, non-viewer userIds for an opportunity (actors can repeat). */
118
132
  const getUniqueCounterpartUserIds = (opp, viewerId) => {
119
133
  const ids = new Set();
@@ -211,7 +225,20 @@ export class HomeGraphFactory {
211
225
  seenIds.add(id);
212
226
  return true;
213
227
  });
214
- return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
228
+ // Preserve byte-identical newest-first behavior while the flag is
229
+ // off. When on, re-order the already-deduped lifecycle set by
230
+ // adjusted confidence so each client-side radar bucket reflects
231
+ // pool answers immediately.
232
+ if (poolQuestionsRanking() !== 'on') {
233
+ return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
234
+ }
235
+ const adjustedOrder = [...dedupedByCounterpart].sort((a, b) => {
236
+ const confidenceDelta = getConfidence(b) - getConfidence(a);
237
+ if (confidenceDelta !== 0)
238
+ return confidenceDelta;
239
+ return safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt);
240
+ });
241
+ return { opportunities: adjustedOrder.slice(0, state.limit) };
215
242
  }
216
243
  const sorted = [...visibleForFeed].sort((a, b) => {
217
244
  // Connections before connector-flow so dedup claims counterpart IDs
@@ -278,7 +305,15 @@ export class HomeGraphFactory {
278
305
  const originalIndex = opportunities.indexOf(cacheable[i]);
279
306
  // Stamp the live status: pre-status cache entries lack the field,
280
307
  // and the key already guarantees it matches the current status.
281
- cachedCards.set(cacheable[i].id, { ...cached, status: cacheable[i].status, _cardIndex: originalIndex });
308
+ const deprioritizedReason = poolQuestionsRanking() === 'on'
309
+ ? latestPoolDemotionDetail(cacheable[i].metadata)
310
+ : undefined;
311
+ cachedCards.set(cacheable[i].id, {
312
+ ...cached,
313
+ status: cacheable[i].status,
314
+ deprioritizedReason,
315
+ _cardIndex: originalIndex,
316
+ });
282
317
  }
283
318
  else {
284
319
  uncachedOpportunities.push(cacheable[i]);
@@ -342,7 +377,6 @@ export class HomeGraphFactory {
342
377
  const viewerActor = opportunity.actors.find((a) => a.userId === state.userId);
343
378
  const viewerRole = viewerActor?.role ?? 'party';
344
379
  const isIntroducer = viewerRole === 'introducer';
345
- const isPendingIntroducer = isIntroducer && opportunity.status === 'pending';
346
380
  const preferredActor = pickDisplayCounterpartActor(opportunity, state.userId)
347
381
  ?? opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer');
348
382
  const actorWithProfile = opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer' && !!userMap.get(a.userId));
@@ -537,8 +571,16 @@ export class HomeGraphFactory {
537
571
  const cachePresenterResultsNode = async (state) => {
538
572
  return timed("HomeGraph.cachePresenterResults", async () => {
539
573
  const { cards, cachedCards, userId, opportunities } = state;
574
+ const liveById = new Map(opportunities.map((opportunity) => [opportunity.id, opportunity]));
575
+ const cardsWithAdjustments = cards.map((card) => {
576
+ const opportunity = liveById.get(card.opportunityId);
577
+ const deprioritizedReason = poolQuestionsRanking() === 'on'
578
+ ? latestPoolDemotionDetail(opportunity?.metadata)
579
+ : undefined;
580
+ return { ...card, deprioritizedReason };
581
+ });
540
582
  // Only cache cards that weren't already from cache
541
- const newCards = cards.filter((card) => !cachedCards.has(card.opportunityId));
583
+ const newCards = cardsWithAdjustments.filter((card) => !cachedCards.has(card.opportunityId));
542
584
  const statusById = new Map(opportunities.map((opp) => [opp.id, opp.status]));
543
585
  try {
544
586
  await Promise.all(newCards.map((card) => {
@@ -561,9 +603,9 @@ export class HomeGraphFactory {
561
603
  cachePresenterResultsLog.warn('cache write failed, continuing', { error: e });
562
604
  }
563
605
  // Merge cached cards into full card list
564
- const allCards = [...cards];
606
+ const allCards = [...cardsWithAdjustments];
565
607
  for (const [oppId, cachedCard] of cachedCards) {
566
- if (!cards.some((c) => c.opportunityId === oppId)) {
608
+ if (!cardsWithAdjustments.some((card) => card.opportunityId === oppId)) {
567
609
  allCards.push(cachedCard);
568
610
  }
569
611
  }
@@ -585,12 +627,13 @@ export class HomeGraphFactory {
585
627
  return { categoryCacheHit: false };
586
628
  }
587
629
  // Skeleton runs never categorize (some cards have no text to categorize
588
- // and the response must stay LLM-free): emit flat sections and mark the
589
- // categorizer as "hit" so the generate node and the cache write are both
590
- // skipped. Chunked by MAX_ITEMS_PER_SECTION because normalizeAndSort
591
- // caps every section — one giant section would silently drop cards that
592
- // the follow-up full request (multiple sections) does return.
593
- if (state.presentation === 'skeleton') {
630
+ // and the response must stay LLM-free). Ranking-enabled lifecycle views
631
+ // also stay flat: the intent Radar flattens sections and buckets by
632
+ // status, so dynamic categorization would erase the adjusted order on
633
+ // the full second-phase response. Flag off retains the legacy path.
634
+ // Chunk by MAX_ITEMS_PER_SECTION because normalizeAndSort caps sections.
635
+ const preserveAdjustedLifecycleOrder = (state.statuses?.length ?? 0) > 0 && poolQuestionsRanking() === 'on';
636
+ if (state.presentation === 'skeleton' || preserveAdjustedLifecycleOrder) {
594
637
  const sectionProposals = [];
595
638
  for (let start = 0; start < state.cards.length; start += MAX_ITEMS_PER_SECTION) {
596
639
  sectionProposals.push({