diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index 7f3dec7..43bb33a 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -84,6 +84,8 @@ export interface CoreRuntimeConfig { entityGraphEnabled: boolean; entitySearchMinSimilarity: number; hybridSearchEnabled: boolean; + instructionBoostEnabled: boolean; + instructionBoostWeight: number; iterativeRetrievalEnabled: boolean; lessonsEnabled: boolean; linkExpansionBeforeMMR: boolean; diff --git a/src/config.ts b/src/config.ts index 10025d0..0ef230b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,6 +125,16 @@ export interface RuntimeConfig { rerankSkipMinGap: number; literalListProtectionEnabled: boolean; literalListProtectionMaxProtected: number; + /** + * EXP-05: when true, retrieval boosts memories tagged with + * `metadata.fact_role: 'instruction'` (set by extraction enrichment for + * imperative phrasing — "always X", "never Y", "from now on", etc.). + * Defaults-off per Sprint 2 rule. Tagging at ingest is unconditional; + * the boost itself is gated. See `src/services/instruction-boost.ts`. + */ + instructionBoostEnabled: boolean; + /** EXP-05: additive score boost applied to instruction-tagged results. */ + instructionBoostWeight: number; temporalQueryConstraintEnabled: boolean; temporalQueryConstraintBoost: number; deferredAudnEnabled: boolean; @@ -372,6 +382,8 @@ export const config: RuntimeConfig = { rerankSkipMinGap: parseFloat(optionalEnv('RERANK_SKIP_MIN_GAP') ?? '0.05'), literalListProtectionEnabled: (optionalEnv('LITERAL_LIST_PROTECTION_ENABLED') ?? 'false') === 'true', literalListProtectionMaxProtected: parsePositiveIntEnv('LITERAL_LIST_PROTECTION_MAX_PROTECTED', 3), + instructionBoostEnabled: (optionalEnv('INSTRUCTION_BOOST_ENABLED') ?? 'false') === 'true', + instructionBoostWeight: parseFloat(optionalEnv('INSTRUCTION_BOOST_WEIGHT') ?? '0.15'), temporalQueryConstraintEnabled: (optionalEnv('TEMPORAL_QUERY_CONSTRAINT_ENABLED') ?? 'false') === 'true', temporalQueryConstraintBoost: parseFloat(optionalEnv('TEMPORAL_QUERY_CONSTRAINT_BOOST') ?? '2'), deferredAudnEnabled: (optionalEnv('DEFERRED_AUDN_ENABLED') ?? 'false') === 'true', @@ -512,6 +524,8 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [ 'rerankSkipTopSimilarity', 'rerankSkipMinGap', // Literal/list answer selection 'literalListProtectionEnabled', 'literalListProtectionMaxProtected', + // Instruction-type retrieval boost (EXP-05) + 'instructionBoostEnabled', 'instructionBoostWeight', // Temporal query selection 'temporalQueryConstraintEnabled', 'temporalQueryConstraintBoost', // Fast AUDN diff --git a/src/db/repository-types.ts b/src/db/repository-types.ts index 0f5aa18..d612915 100644 --- a/src/db/repository-types.ts +++ b/src/db/repository-types.ts @@ -49,6 +49,8 @@ export const RESERVED_METADATA_KEYS = new Set([ 'clarification_note', 'target_memory_id', 'contradiction_confidence', + // Instruction tagging — `src/services/extraction-enrichment.ts` (EXP-05) + 'fact_role', ]); /** diff --git a/src/services/__tests__/extraction-enrichment.test.ts b/src/services/__tests__/extraction-enrichment.test.ts index db29d9b..d648105 100644 --- a/src/services/__tests__/extraction-enrichment.test.ts +++ b/src/services/__tests__/extraction-enrichment.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest'; -import { enrichExtractedFact } from '../extraction-enrichment.js'; +import { + detectInstructionFact, + enrichExtractedFact, +} from '../extraction-enrichment.js'; import type { ExtractedFact } from '../extraction.js'; describe('enrichExtractedFact', () => { @@ -76,3 +79,71 @@ function buildFact(factText: string, keywords: string[]): ExtractedFact { relations: [], }; } + +describe('instruction tagging (EXP-05)', () => { + const INSTRUCTION_PHRASES = [ + 'Always respond in formal English.', + 'Never share my email address.', + 'From now on, summarize replies in three bullets.', + 'Please remember that I prefer Celsius.', + 'Make sure to cite sources for every claim.', + "Don't forget to greet me by name.", + 'Every time I ask for code, include tests.', + 'Whenever you mention prices, use USD.', + 'Going forward, omit emoji from responses.', + 'In the future, default to Python over JavaScript.', + 'Remember to mention deployment caveats.', + ]; + + for (const phrase of INSTRUCTION_PHRASES) { + it(`detects an imperative marker in: "${phrase}"`, () => { + expect(detectInstructionFact(phrase)).toBe(true); + }); + } + + it('does not flag a regular factual statement as an instruction', () => { + expect(detectInstructionFact('User is building a personal finance tracker.')).toBe(false); + expect(detectInstructionFact('User favorite color is blue.')).toBe(false); + }); + + it('tags fact metadata with fact_role=instruction and floors importance', () => { + const fact = buildFact('Always respond to me in formal English.', []); + + const enriched = enrichExtractedFact(fact); + + expect(enriched.metadata).toMatchObject({ fact_role: 'instruction' }); + expect(enriched.importance).toBeGreaterThanOrEqual(0.95); + }); + + it('preserves importance above the floor when already higher', () => { + const fact = { ...buildFact('Never log my passwords.', []), importance: 0.99 }; + + const enriched = enrichExtractedFact(fact); + + expect(enriched.importance).toBe(0.99); + expect(enriched.metadata?.fact_role).toBe('instruction'); + }); + + it('does not add fact_role for non-imperative facts', () => { + const fact = buildFact('User is using Tailwind CSS for the finance tracker.', ['Tailwind CSS']); + + const enriched = enrichExtractedFact(fact); + + expect(enriched.metadata?.fact_role).toBeUndefined(); + expect(enriched.importance).toBe(0.7); + }); + + it('merges with caller-supplied metadata without clobbering it', () => { + const fact: ExtractedFact = { + ...buildFact('Always send me weekly summaries.', []), + metadata: { custom_tag: 'value' }, + }; + + const enriched = enrichExtractedFact(fact); + + expect(enriched.metadata).toMatchObject({ + custom_tag: 'value', + fact_role: 'instruction', + }); + }); +}); diff --git a/src/services/__tests__/instruction-boost.test.ts b/src/services/__tests__/instruction-boost.test.ts new file mode 100644 index 0000000..70150b1 --- /dev/null +++ b/src/services/__tests__/instruction-boost.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for the EXP-05 instruction-type retrieval boost. + * + * Covers: + * - feature-flag off → no-op (returns input reference, no reorder). + * - feature-flag on → instruction-tagged results gain `boostWeight`. + * - feature-flag on → reorders results when boost overtakes a higher base score. + * - non-instruction results are untouched in score and identity. + * - empty input returns the empty reference unchanged. + * - weight is taken verbatim from config (additive, not multiplicative). + */ + +import { describe, expect, it } from 'vitest'; +import { applyInstructionBoost, type InstructionBoostConfig } from '../instruction-boost.js'; +import { createSearchResult } from './test-fixtures.js'; +import type { SearchResult } from '../../db/repository-types.js'; + +const QUERY = 'how should I respond?'; + +function makeInstructionResult(id: string, score: number): SearchResult { + return createSearchResult({ + id, + content: `instruction memory ${id}`, + score, + similarity: score, + metadata: { fact_role: 'instruction' }, + }); +} + +function makeRegularResult(id: string, score: number): SearchResult { + return createSearchResult({ + id, + content: `regular memory ${id}`, + score, + similarity: score, + metadata: {}, + }); +} + +describe('applyInstructionBoost', () => { + it('returns the input reference unchanged when the flag is off', () => { + const results = [makeInstructionResult('a', 0.4), makeRegularResult('b', 0.6)]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: false, + instructionBoostWeight: 0.15, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + expect(out).toBe(results); + expect(out.map((r) => r.id)).toEqual(['a', 'b']); + expect(out.map((r) => r.score)).toEqual([0.4, 0.6]); + }); + + it('adds the configured weight to instruction-tagged results', () => { + const results = [makeInstructionResult('a', 0.50), makeRegularResult('b', 0.40)]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.15, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + const a = out.find((r) => r.id === 'a'); + const b = out.find((r) => r.id === 'b'); + expect(a?.score).toBeCloseTo(0.65, 10); + expect(b?.score).toBe(0.40); + }); + + it('reorders results when the boost lifts an instruction past a higher-scored peer', () => { + const results = [makeRegularResult('regular', 0.60), makeInstructionResult('instr', 0.50)]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.15, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + expect(out.map((r) => r.id)).toEqual(['instr', 'regular']); + }); + + it('does not mutate the input array or its elements', () => { + const original = [makeInstructionResult('a', 0.5), makeRegularResult('b', 0.6)]; + const snapshot = original.map((r) => ({ id: r.id, score: r.score })); + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.2, + }; + + applyInstructionBoost(original, QUERY, config); + + expect(original.map((r) => ({ id: r.id, score: r.score }))).toEqual(snapshot); + }); + + it('is a no-op for an empty input list', () => { + const empty: SearchResult[] = []; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.15, + }; + + const out = applyInstructionBoost(empty, QUERY, config); + + expect(out).toBe(empty); + }); + + it('respects a weight of 0 (no effective change in scores)', () => { + const results = [makeInstructionResult('a', 0.5), makeRegularResult('b', 0.4)]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + expect(out.find((r) => r.id === 'a')?.score).toBe(0.5); + expect(out.find((r) => r.id === 'b')?.score).toBe(0.4); + expect(out.map((r) => r.id)).toEqual(['a', 'b']); + }); + + it('skips results whose metadata.fact_role is not "instruction"', () => { + const results = [ + createSearchResult({ id: 'other-role', score: 0.5, metadata: { fact_role: 'observation' } }), + makeRegularResult('plain', 0.6), + ]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.5, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + expect(out.find((r) => r.id === 'other-role')?.score).toBe(0.5); + expect(out.find((r) => r.id === 'plain')?.score).toBe(0.6); + }); + + it('boosts every instruction-tagged result, not just the first', () => { + const results = [ + makeInstructionResult('i1', 0.30), + makeInstructionResult('i2', 0.20), + makeRegularResult('r1', 0.50), + ]; + const config: InstructionBoostConfig = { + instructionBoostEnabled: true, + instructionBoostWeight: 0.10, + }; + + const out = applyInstructionBoost(results, QUERY, config); + + expect(out.find((r) => r.id === 'i1')?.score).toBeCloseTo(0.40, 10); + expect(out.find((r) => r.id === 'i2')?.score).toBeCloseTo(0.30, 10); + expect(out.find((r) => r.id === 'r1')?.score).toBe(0.50); + // r1 still wins (0.50 > 0.40), but both instructions retain new scores. + expect(out.map((r) => r.id)).toEqual(['r1', 'i1', 'i2']); + }); +}); diff --git a/src/services/extraction-enrichment.ts b/src/services/extraction-enrichment.ts index 4086f99..1653bae 100644 --- a/src/services/extraction-enrichment.ts +++ b/src/services/extraction-enrichment.ts @@ -17,6 +17,32 @@ const WORKS_ON_MARKERS = ['building', 'working on', 'started', 'created', 'built const KNOWS_MARKERS = ['advisor', 'career advice from', 'supportive', 'recommendation letter', 'recommended by']; const STUDIES_MARKERS = ['focus on', 'studying', 'research', 'paper on']; +/** + * Imperative phrasing markers that flag an extracted fact as an explicit + * instruction. Detection is intentionally conservative — false positives on + * assistant tutorial content are acceptable since extraction prefixes those + * with "Assistant mentioned:". When a marker hits, the fact gains + * `metadata.fact_role: 'instruction'` and its importance is floored to 0.95 + * so lifecycle decay does not evict it. + */ +const INSTRUCTION_MARKERS = [ + 'always ', + 'never ', + 'from now on', + 'please remember', + 'make sure to', + "don't forget", + 'do not forget', + 'every time', + 'whenever you', + 'going forward', + 'in the future', + 'remember to', +] as const; + +/** Floor for instruction-tagged importance — see plan EXP-05 risks (2). */ +const INSTRUCTION_IMPORTANCE_FLOOR = 0.95; + const TOOL_NAMES = new Set([ 'React', 'TypeScript', @@ -70,7 +96,30 @@ export function enrichExtractedFact(fact: ExtractedFact): ExtractedFact { ...inferRelations(fact.fact, withSelf), ]); - return { ...fact, entities: withSelf, relations }; + return applyInstructionTagging({ ...fact, entities: withSelf, relations }); +} + +/** + * Detect imperative-phrased facts (always/never/from-now-on/...) and tag + * them with `metadata.fact_role: 'instruction'`. Floors importance to + * INSTRUCTION_IMPORTANCE_FLOOR so lifecycle decay preserves them. + * + * Pure: returns the input unchanged when no marker matches. + */ +function applyInstructionTagging(fact: ExtractedFact): ExtractedFact { + if (!detectInstructionFact(fact.fact)) return fact; + const existingMetadata = fact.metadata ?? {}; + return { + ...fact, + importance: Math.max(fact.importance, INSTRUCTION_IMPORTANCE_FLOOR), + metadata: { ...existingMetadata, fact_role: 'instruction' }, + }; +} + +/** Returns true when the fact text contains an imperative instruction marker. */ +export function detectInstructionFact(text: string): boolean { + const lower = ` ${text.toLowerCase()} `; + return INSTRUCTION_MARKERS.some((marker) => lower.includes(marker)); } function shouldAddSelfEntity(text: string): boolean { diff --git a/src/services/extraction.ts b/src/services/extraction.ts index 3ce6730..558e046 100644 --- a/src/services/extraction.ts +++ b/src/services/extraction.ts @@ -166,6 +166,14 @@ export interface ExtractedFact { network?: 'world' | 'experience' | 'opinion' | 'observation'; /** Opinion confidence [0,1], only set when network='opinion'. */ opinionConfidence?: number | null; + /** + * Optional structured metadata propagated to the persisted memory's + * `metadata` map. Used by post-extraction enrichers (e.g. instruction + * tagging in `extraction-enrichment.ts`) to attach retrieval-time signals + * without inflating the core extraction surface. Reserved keys are + * enforced by the `RESERVED_METADATA_KEYS` drift test. + */ + metadata?: Record; } export type AUDNAction = 'ADD' | 'UPDATE' | 'DELETE' | 'SUPERSEDE' | 'NOOP' | 'CLARIFY'; diff --git a/src/services/instruction-boost.ts b/src/services/instruction-boost.ts new file mode 100644 index 0000000..bf03bc9 --- /dev/null +++ b/src/services/instruction-boost.ts @@ -0,0 +1,55 @@ +/** + * Instruction-type retrieval boost (EXP-05). + * + * Adds a configurable additive weight to search results whose persisted + * `metadata.fact_role` is `'instruction'`, then re-sorts by score. Memory + * systems dilute imperative instructions when they sit alongside thousands + * of regular memories; this stage recovers the signal at retrieval time + * without changing the underlying ranking primitives. + * + * Tagging happens in `extraction-enrichment.ts` (`applyInstructionTagging`). + * Both halves are gated by `instructionBoostEnabled` (default false). + * + * Defaults preserve current behavior: when the flag is off the function + * returns the input array reference unchanged. + */ + +import type { SearchResult } from '../db/repository-types.js'; + +export interface InstructionBoostConfig { + /** Master flag. When false, this stage is a strict no-op. */ + instructionBoostEnabled: boolean; + /** Additive boost applied to instruction-tagged results' `score`. */ + instructionBoostWeight: number; +} + +/** + * Apply the instruction-type retrieval boost. + * + * - Returns the input reference unchanged when the flag is off. + * - Otherwise returns a new array sorted by adjusted score (descending). + * Inputs are not mutated. + * + * `_query` is reserved for future query-aware gating (e.g. only boost when + * the query itself is imperative). Currently unused — the boost fires for + * every query so instruction memories surface across all retrieval paths. + */ +export function applyInstructionBoost( + results: SearchResult[], + _query: string, + config: InstructionBoostConfig, +): SearchResult[] { + if (!config.instructionBoostEnabled) return results; + if (results.length === 0) return results; + + const adjusted = results.map((result) => { + if (!isInstructionResult(result)) return result; + return { ...result, score: result.score + config.instructionBoostWeight }; + }); + adjusted.sort((a, b) => b.score - a.score); + return adjusted; +} + +function isInstructionResult(result: SearchResult): boolean { + return result.metadata?.fact_role === 'instruction'; +} diff --git a/src/services/memcell-projection.ts b/src/services/memcell-projection.ts index 9256fab..ff3c9b5 100644 --- a/src/services/memcell-projection.ts +++ b/src/services/memcell-projection.ts @@ -26,6 +26,7 @@ export function buildAtomicFactProjection( importance: fact.importance, keywords: fact.keywords, metadata: { + ...(fact.metadata ?? {}), headline: fact.headline, entities: fact.entities, relations: fact.relations, diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 0187247..c685502 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -18,6 +18,12 @@ export interface FactInput { relations: ExtractedRelation[]; network?: MemoryNetwork; opinionConfidence?: number | null; + /** + * Structured metadata propagated to the persisted memory's `metadata` map. + * Currently used by instruction tagging in `extraction-enrichment.ts` + * (key: `fact_role`). Reserved keys enforced by RESERVED_METADATA_KEYS. + */ + metadata?: Record; } export interface ClaimTarget { diff --git a/src/services/memory-storage.ts b/src/services/memory-storage.ts index 6e37c4d..478301e 100644 --- a/src/services/memory-storage.ts +++ b/src/services/memory-storage.ts @@ -29,6 +29,24 @@ interface StoreProjectionOptions { workspace?: import('../db/repository-types.js').WorkspaceContext; } +/** + * Compose the persisted memory's `metadata` map by overlaying the optional + * fact-supplied metadata (from extraction enrichers, e.g. instruction + * tagging) and the lineage `cmo_id`. Returns `undefined` when neither is + * present so we don't write an empty map. + * + * `cmo_id` always wins on conflict — fact metadata cannot clobber lineage. + */ +function mergeStoreMetadata( + cmoId: string | undefined, + factMetadata: Record | undefined, +): import('../db/repository-types.js').MemoryMetadata | undefined { + if (!cmoId && (!factMetadata || Object.keys(factMetadata).length === 0)) return undefined; + const merged: Record = { ...(factMetadata ?? {}) }; + if (cmoId) merged.cmo_id = cmoId; + return merged as import('../db/repository-types.js').MemoryMetadata; +} + /** Store a new canonical fact: CMO, projection, claim, evidence, entities. */ export async function storeCanonicalFact( deps: MemoryServiceDeps, @@ -93,7 +111,7 @@ export async function storeProjection( userId, content: fact.fact, embedding, memoryType: fact.type === 'knowledge' ? 'semantic' : 'episodic', importance: fact.importance, sourceSite, sourceUrl, episodeId, - metadata: options.cmoId ? { cmo_id: options.cmoId } : undefined, + metadata: mergeStoreMetadata(options.cmoId, fact.metadata), keywords: fact.keywords.join(' '), namespace: namespace ?? undefined, summary: fact.headline, diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 1c4d445..568dd18 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -36,6 +36,7 @@ import { DEFAULT_RRF_K, weightedRRF } from './rrf-fusion.js'; import { applyIterativeRetrieval } from './iterative-retrieval.js'; import { applyCurrentStateRanking } from './current-state-ranking.js'; import { applyConcisenessPenalty } from './conciseness-preference.js'; +import { applyInstructionBoost } from './instruction-boost.js'; import { protectLiteralListAnswerCandidates } from './literal-list-protection.js'; import { applyTemporalQueryConstraints } from './temporal-query-constraints.js'; @@ -59,6 +60,8 @@ export type SearchPipelineRuntimeConfig = Pick< | 'entityGraphEnabled' | 'entitySearchMinSimilarity' | 'hybridSearchEnabled' + | 'instructionBoostEnabled' + | 'instructionBoostWeight' | 'iterativeRetrievalEnabled' | 'linkExpansionBeforeMMR' | 'linkExpansionEnabled' @@ -758,9 +761,32 @@ function applyRankingProtectionStages( state = { ...state, candidates: currentStateRanked.results }; } + state = applyInstructionBoostStage(query, state, trace, policyConfig); + return { ...state, candidates: applyConcisenessPenalty(state.candidates) }; } +/** + * Wraps `applyInstructionBoost` with trace emission. No-op when the feature + * flag is off (the boost function itself short-circuits). EXP-05. + */ +function applyInstructionBoostStage( + query: string, + state: RankedCandidateState, + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): RankedCandidateState { + if (!policyConfig.instructionBoostEnabled) return state; + const boosted = applyInstructionBoost(state.candidates, query, { + instructionBoostEnabled: policyConfig.instructionBoostEnabled, + instructionBoostWeight: policyConfig.instructionBoostWeight, + }); + trace.stage('instruction-boost', boosted, { + weight: policyConfig.instructionBoostWeight, + }); + return { ...state, candidates: boosted }; +} + function applySubjectRankingStage( query: string, candidates: SearchResult[],